| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065 |
- 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");
- 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 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("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");
- await mkdir(storyDir, { recursive: true });
- await Promise.all([
- 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,
- chapterIntent: "# Chapter Intent\n\n## Goal\nForce Mara back toward the ledger trail.\n",
- 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,
- chapterIntent: "# Chapter Intent\n\n## Goal\nPush Mara back toward the archive ledger.\n",
- 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("renders an explicit hook agenda block and removes placeholder hook ids from the governed write contract", 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,
- chapterIntent: [
- "# Chapter Intent",
- "",
- "## Goal",
- "Push Mara back toward the archive ledger.",
- "",
- "## Hook Agenda",
- "### Must Advance",
- "- mentor-oath",
- "",
- "### Eligible Resolve",
- "- ledger-fragment",
- "",
- "### Stale Debt",
- "- stale-ledger",
- "",
- "### Avoid New Hook Families",
- "- relationship",
- ].join("\n"),
- 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");
- expect(creativePrompt).toContain("## Explicit Hook Agenda");
- expect(creativePrompt).toContain("mentor-oath");
- expect(creativePrompt).toContain("ledger-fragment");
- expect(creativePrompt).toContain("stale-ledger");
- expect(creativePrompt).toContain("relationship");
- } finally {
- await rm(root, { recursive: true, force: true });
- }
- });
- });
|