| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244 |
- import { afterEach, describe, expect, it, vi } from "vitest";
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { WriterAgent } from "../agents/writer.js";
- import { buildLengthSpec } from "../utils/length-metrics.js";
- const ZERO_USAGE = {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- } as const;
- function createCaptureLogger() {
- const infos: string[] = [];
- const warnings: string[] = [];
- const logger = {
- debug() {},
- info(message: string) {
- infos.push(message);
- },
- warn(message: string) {
- warnings.push(message);
- },
- error() {},
- child() {
- return logger;
- },
- };
- return { logger, infos, warnings };
- }
- describe("WriterAgent", () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
- it("uses compact summary context plus selected long-range evidence during governed settlement", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "# Pending Hooks",
- "",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
- "| old-seal | 3 | artifact | open | 12 | 40 | Old seal detour |",
- "| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |",
- "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "# Chapter Summaries",
- "",
- "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
- "| 97 | Shrine Ash | Lin Yue | The old shrine proves empty | Frustration rises | none | bitter | setback |",
- "| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
- "| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "subplot_board.md"), [
- "# 支线进度板",
- "",
- "| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |",
- "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
- "| SP-mentor | 师债线 | Lin Yue | 8 | 99 | 1 | active | 师债继续推进 | 101 |",
- "| SP-seal | 旧印支线 | Guildmaster Ren | 3 | 12 | 88 | closed | 旧印已回收 | 12 |",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "emotional_arcs.md"), [
- "# 情感弧线",
- "",
- "| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |",
- "| --- | --- | --- | --- | --- | --- |",
- "| Lin Yue | 40 | 麻木 | 旧印支线拖延 | 4 | 停滞 |",
- "| Lin Yue | 99 | 紧绷 | 师债重新压上来 | 8 | 收紧 |",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "character_matrix.md"), [
- "# 角色交互矩阵",
- "",
- "### 角色档案",
- "| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| Lin Yue | oath | restraint | clipped | stubborn | self | repay debt | find mentor |",
- "| Guildmaster Ren | guild | swagger | loud | opportunistic | rival | stall Mara | seize seal |",
- ].join("\n"), "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "A Decision",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue turned away from the guild trail and chose the mentor debt.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
- "",
- "=== UPDATED_STATE ===",
- "状态卡",
- "",
- "=== UPDATED_HOOKS ===",
- "伏笔池",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 100 | A Decision | Lin Yue | Chooses the mentor debt | Focus narrowed | mentor-oath advanced | tense | decision |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "支线板",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "情感弧线",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "角色矩阵",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 120,
- chapterWordCount: 2200,
- createdAt: "2026-03-23T00:00:00.000Z",
- updatedAt: "2026-03-23T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 100,
- chapterIntent: [
- "# Chapter Intent",
- "",
- "## Goal",
- "Bring the focus back to the mentor oath conflict.",
- "",
- "## Hook Agenda",
- "### Must Advance",
- "- mentor-oath",
- "",
- "### Eligible Resolve",
- "- none",
- "",
- "### Stale Debt",
- "- stale-ledger",
- "",
- "### Avoid New Hook Families",
- "- none",
- ].join("\n"),
- contextPackage: {
- chapter: 100,
- selectedContext: [
- {
- source: "story/volume_outline.md",
- reason: "Anchor the current beat.",
- excerpt: "Bring the focus back to the mentor oath conflict.",
- },
- {
- source: "story/chapter_summaries.md#99",
- reason: "Relevant episodic memory.",
- excerpt: "Locked Gate | Lin Yue chooses the mentor line over the guild line | mentor-oath advanced",
- },
- {
- source: "story/pending_hooks.md#mentor-oath",
- reason: "Carry forward unresolved hook.",
- excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
- },
- {
- source: "runtime/hook_debt#mentor-oath",
- reason: "Explicit hook debt brief for the agenda target.",
- excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath",
- },
- ],
- },
- ruleStack: {
- layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
- sections: {
- hard: ["current_state"],
- soft: ["current_focus"],
- diagnostic: ["continuity_audit"],
- },
- overrideEdges: [],
- activeOverrides: [],
- },
- lengthSpec: buildLengthSpec(220, "zh"),
- });
- const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
- expect(settlePrompt).toContain("## 本章控制输入");
- expect(settlePrompt).toContain("story/chapter_summaries.md#99");
- expect(settlePrompt).toContain("| 99 | Locked Gate |");
- expect(settlePrompt).toContain("## Hook Debt Briefs");
- expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn");
- expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | 中程 | Old ledger debt is dormant but unresolved |");
- expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
- expect(settlePrompt).not.toContain("old-seal");
- expect(settlePrompt).not.toContain("Guildmaster Ren");
- expect(settlePrompt).not.toContain("| Lin Yue | 40 | 麻木 |");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("builds structured runtime-state artifacts when settler returns a delta", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(storyDir, { recursive: true });
- await mkdir(chaptersDir, { recursive: true });
- await Promise.all([
- writeFile(join(chaptersDir, "index.json"), JSON.stringify([
- { number: 1, title: "Ch1", status: "approved" },
- { number: 2, title: "Ch2", status: "approved" },
- ]), "utf-8"),
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 2 |",
- "| Current Goal | Find the vanished mentor |",
- "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
- "",
- ].join("\n"), "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "River Ledger",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue follows the debt into the river-port ledger.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- mentor-debt advanced",
- "",
- "=== RUNTIME_STATE_DELTA ===",
- "```json",
- JSON.stringify({
- chapter: 3,
- currentStatePatch: {
- currentGoal: "Trace the debt through the river-port ledger.",
- currentConflict: "Guild pressure keeps colliding with the debt trail.",
- },
- hookOps: {
- upsert: [
- {
- hookId: "mentor-debt",
- startChapter: 1,
- type: "relationship",
- status: "progressing",
- lastAdvancedChapter: 3,
- expectedPayoff: "Reveal the debt.",
- notes: "The ledger clue sharpens the line.",
- },
- ],
- resolve: [],
- defer: [],
- },
- chapterSummary: {
- chapter: 3,
- title: "River Ledger",
- characters: "Lin Yue",
- events: "Lin Yue follows the debt into the river-port ledger.",
- stateChanges: "The debt line sharpens.",
- hookActivity: "mentor-debt advanced",
- mood: "tense",
- chapterType: "investigation",
- },
- notes: [],
- }, null, 2),
- "```",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- const output = await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-25T00:00:00.000Z",
- updatedAt: "2026-03-25T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 3,
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- expect(output.runtimeStateDelta?.chapter).toBe(3);
- expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
- expect(output.updatedState).toContain("Trace the debt through the river-port ledger.");
- expect(output.updatedHooks).toContain("mentor-debt");
- expect(output.updatedChapterSummaries).toContain("River Ledger");
- expect(output.chapterSummary).toContain("| 3 | River Ledger |");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("overrides hallucinated chapter numbers across both delta and summary row", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(storyDir, { recursive: true });
- await mkdir(chaptersDir, { recursive: true });
- await Promise.all([
- writeFile(join(chaptersDir, "index.json"), JSON.stringify([
- { number: 1, title: "Ch1", status: "approved" },
- { number: 2, title: "Ch2", status: "approved" },
- ]), "utf-8"),
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The city still remembers 1988.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 2 |",
- "| Current Goal | Find the vanished mentor |",
- "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
- "",
- ].join("\n"), "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "River Ledger",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue follows the debt into the river-port ledger. The old wall still carries the year 1988.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- mentor-debt advanced",
- "",
- "=== RUNTIME_STATE_DELTA ===",
- "```json",
- JSON.stringify({
- chapter: 1988,
- currentStatePatch: {
- currentGoal: "Trace the debt through the river-port ledger.",
- currentConflict: "Guild pressure keeps colliding with the debt trail.",
- },
- hookOps: {
- upsert: [
- {
- hookId: "mentor-debt",
- startChapter: 1,
- type: "relationship",
- status: "progressing",
- lastAdvancedChapter: 1988,
- expectedPayoff: "Reveal the debt.",
- notes: "The ledger clue sharpens the line.",
- },
- ],
- resolve: [],
- defer: [],
- },
- chapterSummary: {
- chapter: 1988,
- title: "River Ledger",
- characters: "Lin Yue",
- events: "Lin Yue follows the debt into the river-port ledger.",
- stateChanges: "The debt line sharpens.",
- hookActivity: "mentor-debt advanced",
- mood: "tense",
- chapterType: "investigation",
- },
- notes: [],
- }, null, 2),
- "```",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- const output = await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-25T00:00:00.000Z",
- updatedAt: "2026-03-25T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 3,
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- expect(output.runtimeStateDelta?.chapter).toBe(3);
- expect(output.runtimeStateDelta?.chapterSummary?.chapter).toBe(3);
- expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
- expect(output.runtimeStateSnapshot?.hooks.hooks[0]?.lastAdvancedChapter).toBe(3);
- expect(output.updatedHooks).toContain("| mentor-debt | 1 | relationship | progressing | 3 |");
- expect(output.updatedChapterSummaries).toContain("| 3 | River Ledger |");
- expect(output.chapterSummary).toContain("| 3 | River Ledger |");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(storyDir, { recursive: true });
- await mkdir(chaptersDir, { recursive: true });
- await Promise.all([
- writeFile(join(chaptersDir, "index.json"), JSON.stringify([
- { number: 1, title: "Ch1", status: "approved" },
- { number: 2, title: "Ch2", status: "approved" },
- ]), "utf-8"),
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Anonymous messages keep steering the debt trail.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), [
- "# Current State",
- "",
- "| Field | Value |",
- "| --- | --- |",
- "| Current Chapter | 2 |",
- "| Current Goal | Find who fed the route to the anonymous source |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| anonymous-source-scope | 1 | source-risk | open | 2 | Reveal how much the anonymous source already knew about the route. | The source knowledge question remains unresolved. |",
- "",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 2 | Route Leak | Lin Yue | An anonymous source already knew the route | Suspicion sharpens | anonymous-source-scope advanced | tense | mainline |",
- "",
- ].join("\n"), "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "Address Leak",
- "",
- "=== CHAPTER_CONTENT ===",
- "Lin Yue realizes the anonymous source knew the address, not just the route.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- source scope widens",
- "",
- "=== RUNTIME_STATE_DELTA ===",
- "```json",
- JSON.stringify({
- chapter: 3,
- hookOps: {
- upsert: [],
- mention: [],
- resolve: [],
- defer: [],
- },
- newHookCandidates: [
- {
- type: "source-risk",
- expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
- notes: "This chapter adds the address angle to the anonymous source question.",
- },
- ],
- chapterSummary: {
- chapter: 3,
- title: "Address Leak",
- characters: "Lin Yue",
- events: "Lin Yue realizes the anonymous source knew the address.",
- stateChanges: "The source knowledge question widens.",
- hookActivity: "anonymous-source-scope advanced",
- mood: "tight",
- chapterType: "investigation",
- },
- notes: [],
- }, null, 2),
- "```",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- const output = await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "tomato",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-27T00:00:00.000Z",
- updatedAt: "2026-03-27T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 3,
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- expect(output.runtimeStateDelta?.hookOps.upsert).toEqual([
- expect.objectContaining({
- hookId: "anonymous-source-scope",
- lastAdvancedChapter: 3,
- }),
- ]);
- expect(output.runtimeStateDelta?.newHookCandidates).toEqual([]);
- expect(output.updatedHooks).toContain("anonymous-source-scope");
- expect(output.updatedHooks).toContain("| anonymous-source-scope | 1 | source-risk | progressing | 3 |");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("logs localized phase messages for Chinese books", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- const { logger, infos } = createCaptureLogger();
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
- writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
- writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
- writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- logger,
- });
- vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "试炼前夜",
- "",
- "=== CHAPTER_CONTENT ===",
- "林越在破庙外停住脚步,想起师门旧债。",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
- "",
- "=== UPDATED_STATE ===",
- "状态卡",
- "",
- "=== UPDATED_HOOKS ===",
- "伏笔池",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 1 | 试炼前夜 | 林越 | 林越记起师门旧债 | 决心加深 | mentor-oath advanced | tense | setup |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "支线板",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "情感弧线",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "角色矩阵",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "tomato",
- genre: "xuanhuan",
- status: "active",
- targetChapters: 120,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-23T00:00:00.000Z",
- updatedAt: "2026-03-23T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 1,
- lengthSpec: buildLengthSpec(220, "zh"),
- });
- expect(infos).toEqual(expect.arrayContaining([
- "阶段 1:创作正文(第1章)",
- "阶段 2:状态结算(第1章,18字)",
- "阶段 2a:提取第1章事实",
- "阶段 2b:把观察结果回写到真相文件",
- ]));
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("injects an English variance brief into governed creative prompts", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-variance-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(storyDir, { recursive: true });
- await mkdir(chaptersDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The registry seals matter.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nForce Mara back toward the ledger trail.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), [
- "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| ledger-fragment | 1 | mystery | open | 3 | 8 | Mara still hides the ledger fragment |",
- ].join("\n"), "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), [
- "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
- "| 1 | Ledger | Mara | Mara hides the ledger | pressure tightens | none | tense | investigation |",
- "| 2 | Ash | Mara,Taryn | Ash falls over the archive | pressure tightens | none | tense | investigation |",
- "| 3 | Harbor | Mara,Taryn | The gate stays under watch | pressure tightens | none | tense | investigation |",
- ].join("\n"), "utf-8"),
- writeFile(join(chaptersDir, "0001_Ledger.md"), "# Chapter 1 Ledger\n\nMara kept the ledger close to her chest. The corridor stayed quiet after the bell. There it was again.\n", "utf-8"),
- writeFile(join(chaptersDir, "0002_Ash.md"), "# Chapter 2 Ash\n\nMara kept the ledger close to her chest while the ash fell. The corridor stayed quiet until Taryn stopped. There it was again.\n", "utf-8"),
- writeFile(join(chaptersDir, "0003_Harbor.md"), "# Chapter 3 Harbor\n\nMara kept the ledger close to her chest near the harbor gate. The corridor stayed quiet while the guards changed. There it was again.\n", "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "Pressure Ledger",
- "",
- "=== CHAPTER_CONTENT ===",
- "Mara forced Taryn to answer beside the archive window.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- ledger-fragment advanced",
- "",
- "=== UPDATED_STATE ===",
- "state",
- "",
- "=== UPDATED_HOOKS ===",
- "hooks",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 4 | Pressure Ledger | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "subplots",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "arcs",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "matrix",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-26T00:00:00.000Z",
- updatedAt: "2026-03-26T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 4,
- chapterBrief: {
- chapter: 4,
- goal: "Force Mara back toward the ledger trail.",
- chapterType: "confrontation",
- isGoldenOpening: false,
- beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive window." }],
- hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
- propsAndSetting: ["archive window"],
- },
- contextPackage: {
- chapter: 4,
- selectedContext: [
- {
- source: "story/chapter_summaries.md#3",
- reason: "Carry recent pressure into the next chapter.",
- excerpt: "The gate stays under watch.",
- },
- ],
- },
- ruleStack: {
- layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
- sections: {
- hard: ["current_state"],
- soft: ["current_focus"],
- diagnostic: ["continuity_audit"],
- },
- overrideEdges: [],
- activeOverrides: [],
- },
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
- expect(creativePrompt).toContain("## English Variance Brief");
- expect(creativePrompt).toContain("High-frequency phrases");
- expect(creativePrompt).toContain("Scene obligation");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("renders explicit title history, mood trail, and canon blocks in governed creative prompts", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-governed-evidence-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "Archive Pressure",
- "",
- "=== CHAPTER_CONTENT ===",
- "Mara corners Taryn beside the archive ledger.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- ledger-fragment advanced",
- "",
- "=== UPDATED_STATE ===",
- "state",
- "",
- "=== UPDATED_HOOKS ===",
- "hooks",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "subplots",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "arcs",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "matrix",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-26T00:00:00.000Z",
- updatedAt: "2026-03-26T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 4,
- chapterBrief: {
- chapter: 4,
- goal: "Push Mara back toward the archive ledger.",
- chapterType: "confrontation",
- isGoldenOpening: false,
- beatOutline: [{ phase: "opening" as const, instruction: "Open with tension at the archive." }],
- hookPlan: [{ hookId: "ledger-fragment", movement: "advance" as const, targetEffect: "Mara is forced to confront the ledger." }],
- propsAndSetting: ["archive"],
- },
- contextPackage: {
- chapter: 4,
- selectedContext: [
- {
- source: "story/chapter_summaries.md#recent_titles",
- reason: "Avoid repeated ledger titles.",
- excerpt: "1: Ledger in Rain | 2: Ledger at Dusk | 3: Harbor Ledger",
- },
- {
- source: "story/chapter_summaries.md#recent_mood_type_trail",
- reason: "Track recent emotional and chapter-type cadence.",
- excerpt: "1: tight / investigation | 2: tight / investigation | 3: tight / investigation",
- },
- {
- source: "story/parent_canon.md",
- reason: "Preserve parent canon constraints.",
- excerpt: "The mentor does not learn about the archive fire until volume two.",
- },
- {
- source: "story/fanfic_canon.md",
- reason: "Preserve extracted fanfic canon constraints.",
- excerpt: "Mara may diverge from the archive route, but the oath debt logic must stay intact.",
- },
- ],
- },
- ruleStack: {
- layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
- sections: {
- hard: ["current_state"],
- soft: ["current_focus"],
- diagnostic: ["continuity_audit"],
- },
- overrideEdges: [],
- activeOverrides: [],
- },
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
- expect(creativePrompt).toContain("## Recent Title History");
- expect(creativePrompt).toContain("Ledger in Rain");
- expect(creativePrompt).toContain("## Recent Mood / Chapter Type Trail");
- expect(creativePrompt).toContain("tight / investigation");
- expect(creativePrompt).toContain("## Canon Evidence");
- expect(creativePrompt).toContain("archive fire until volume two");
- expect(creativePrompt).toContain("oath debt logic must stay intact");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- it("sanitizes governed control inputs so raw hook ids and control headings do not enter the creative prompt", async () => {
- const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-test-"));
- const bookDir = join(root, "book");
- const storyDir = join(bookDir, "story");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
- writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
- writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
- writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
- writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
- writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
- ]);
- const agent = new WriterAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: root,
- });
- const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
- .mockResolvedValueOnce({
- content: [
- "=== CHAPTER_TITLE ===",
- "Archive Pressure",
- "",
- "=== CHAPTER_CONTENT ===",
- "Mara corners Taryn beside the archive ledger.",
- "",
- "=== PRE_WRITE_CHECK ===",
- "- ok",
- ].join("\n"),
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: "=== OBSERVATIONS ===\n- observed",
- usage: ZERO_USAGE,
- })
- .mockResolvedValueOnce({
- content: [
- "=== POST_SETTLEMENT ===",
- "- ledger-fragment advanced",
- "",
- "=== UPDATED_STATE ===",
- "state",
- "",
- "=== UPDATED_HOOKS ===",
- "hooks",
- "",
- "=== CHAPTER_SUMMARY ===",
- "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
- "",
- "=== UPDATED_SUBPLOTS ===",
- "subplots",
- "",
- "=== UPDATED_EMOTIONAL_ARCS ===",
- "arcs",
- "",
- "=== UPDATED_CHARACTER_MATRIX ===",
- "matrix",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- try {
- await agent.writeChapter({
- book: {
- id: "writer-book",
- title: "Writer Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-26T00:00:00.000Z",
- updatedAt: "2026-03-26T00:00:00.000Z",
- },
- bookDir,
- chapterNumber: 4,
- chapterBrief: {
- chapter: 4,
- goal: "Push Mara back toward the archive ledger.",
- chapterType: "confrontation",
- isGoldenOpening: false,
- beatOutline: [{ phase: "opening" as const, instruction: "本章要做的是推进 ledger-fragment tension at the archive." }],
- hookPlan: [
- { hookId: "mentor-oath", movement: "advance" as const, targetEffect: "Advance the oath debt with H001 ledger-fragment callback." },
- { hookId: "ledger-fragment", movement: "partial-payoff" as const, targetEffect: "Partially resolve stale-ledger." },
- ],
- propsAndSetting: ["archive"],
- },
- contextPackage: {
- chapter: 4,
- selectedContext: [
- {
- source: "story/pending_hooks.md#mentor-oath",
- reason: "Carry the unresolved oath line.",
- excerpt: "relationship | open | old oath debt",
- },
- ],
- },
- ruleStack: {
- layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
- sections: {
- hard: ["current_state"],
- soft: ["current_focus"],
- diagnostic: ["continuity_audit"],
- },
- overrideEdges: [],
- activeOverrides: [],
- },
- lengthSpec: buildLengthSpec(2200, "en"),
- });
- const systemPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[0]?.content ?? "";
- const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
- expect(systemPrompt).not.toContain("Hook-A / Hook-B");
- expect(systemPrompt).toContain("真实 hook_id");
- // Enum/identifier fields (hookId, movement, chapterType) are NOT sanitized —
- // the writer needs them to understand which hook to move and what chapter type
- // to write. Free-text fields (goal, instruction, targetEffect) ARE sanitized.
- expect(creativePrompt).not.toContain("## Hook Agenda");
- // hookIds appear verbatim in Hook Plan (identifiers, not free text)
- expect(creativePrompt).toContain("mentor-oath");
- expect(creativePrompt).toContain("ledger-fragment");
- // But slug references INSIDE free text (targetEffect) are sanitized
- expect(creativePrompt).not.toContain("stale-ledger");
- expect(creativePrompt).not.toContain("H001");
- expect(creativePrompt).not.toContain("本章要做的");
- // The goal text should survive sanitization
- expect(creativePrompt).toContain("Push Mara back toward the archive ledger.");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- });
|