architect.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. import { afterEach, describe, expect, it, vi } from "vitest";
  2. import { ArchitectAgent } from "../agents/architect.js";
  3. import type { BookConfig } from "../models/book.js";
  4. const ZERO_USAGE = {
  5. promptTokens: 0,
  6. completionTokens: 0,
  7. totalTokens: 0,
  8. } as const;
  9. describe("ArchitectAgent", () => {
  10. afterEach(() => {
  11. vi.restoreAllMocks();
  12. });
  13. it("uses English prompts when generating foundation from imported English chapters", async () => {
  14. const agent = new ArchitectAgent({
  15. client: {
  16. provider: "openai",
  17. apiFormat: "chat",
  18. stream: false,
  19. defaults: {
  20. temperature: 0.7,
  21. maxTokens: 4096,
  22. thinkingBudget: 0, maxTokensCap: null,
  23. extra: {},
  24. },
  25. },
  26. model: "test-model",
  27. projectRoot: process.cwd(),
  28. });
  29. const book: BookConfig = {
  30. id: "english-book",
  31. title: "English Book",
  32. platform: "other",
  33. genre: "other",
  34. status: "active",
  35. targetChapters: 20,
  36. chapterWordCount: 2200,
  37. language: "en",
  38. createdAt: "2026-03-24T00:00:00.000Z",
  39. updatedAt: "2026-03-24T00:00:00.000Z",
  40. };
  41. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  42. .mockResolvedValue({
  43. content: [
  44. "=== SECTION: story_bible ===",
  45. "# Story Bible",
  46. "",
  47. "=== SECTION: volume_outline ===",
  48. "# Volume Outline",
  49. "",
  50. "=== SECTION: book_rules ===",
  51. "---",
  52. "version: \"1.0\"",
  53. "---",
  54. "",
  55. "# Book Rules",
  56. "",
  57. "=== SECTION: current_state ===",
  58. "# Current State",
  59. "",
  60. "=== SECTION: pending_hooks ===",
  61. "# Pending Hooks",
  62. ].join("\n"),
  63. usage: ZERO_USAGE,
  64. });
  65. await agent.generateFoundationFromImport(
  66. book,
  67. "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
  68. );
  69. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  70. expect(messages[0]?.content).toContain("MUST be written in English");
  71. expect(messages[1]?.content).toContain("Generate the complete foundation");
  72. expect(messages[1]?.content).not.toContain("请从中反向推导");
  73. });
  74. it("does not embed Chinese section headings in imported English foundation prompts", async () => {
  75. const agent = new ArchitectAgent({
  76. client: {
  77. provider: "openai",
  78. apiFormat: "chat",
  79. stream: false,
  80. defaults: {
  81. temperature: 0.7,
  82. maxTokens: 4096,
  83. thinkingBudget: 0, maxTokensCap: null,
  84. extra: {},
  85. },
  86. },
  87. model: "test-model",
  88. projectRoot: process.cwd(),
  89. });
  90. const book: BookConfig = {
  91. id: "english-book",
  92. title: "English Book",
  93. platform: "other",
  94. genre: "other",
  95. status: "active",
  96. targetChapters: 20,
  97. chapterWordCount: 2200,
  98. language: "en",
  99. createdAt: "2026-03-24T00:00:00.000Z",
  100. updatedAt: "2026-03-24T00:00:00.000Z",
  101. };
  102. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  103. .mockResolvedValue({
  104. content: [
  105. "=== SECTION: story_bible ===",
  106. "# Story Bible",
  107. "",
  108. "=== SECTION: volume_outline ===",
  109. "# Volume Outline",
  110. "",
  111. "=== SECTION: book_rules ===",
  112. "---",
  113. "version: \"1.0\"",
  114. "---",
  115. "",
  116. "# Book Rules",
  117. "",
  118. "=== SECTION: current_state ===",
  119. "# Current State",
  120. "",
  121. "=== SECTION: pending_hooks ===",
  122. "# Pending Hooks",
  123. ].join("\n"),
  124. usage: ZERO_USAGE,
  125. });
  126. await agent.generateFoundationFromImport(
  127. book,
  128. "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
  129. );
  130. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  131. // Phase 5: architect prompts describe the new prose sections. The English
  132. // import prompt must not slip Chinese section headers into the system text.
  133. expect(messages[0]?.content).toContain("story_frame");
  134. expect(messages[0]?.content).toContain("volume_map");
  135. expect(messages[0]?.content).not.toContain("## 01_世界观");
  136. expect(messages[0]?.content).not.toContain("## 叙事视角");
  137. });
  138. it("embeds reviewer feedback into original foundation regeneration prompts", async () => {
  139. const agent = new ArchitectAgent({
  140. client: {
  141. provider: "openai",
  142. apiFormat: "chat",
  143. stream: false,
  144. defaults: {
  145. temperature: 0.7,
  146. maxTokens: 4096,
  147. thinkingBudget: 0, maxTokensCap: null,
  148. extra: {},
  149. },
  150. },
  151. model: "test-model",
  152. projectRoot: process.cwd(),
  153. });
  154. const book: BookConfig = {
  155. id: "review-feedback-book",
  156. title: "雾港回灯",
  157. platform: "tomato",
  158. genre: "urban",
  159. status: "active",
  160. targetChapters: 60,
  161. chapterWordCount: 2200,
  162. language: "zh",
  163. createdAt: "2026-04-03T00:00:00.000Z",
  164. updatedAt: "2026-04-03T00:00:00.000Z",
  165. };
  166. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  167. .mockResolvedValue({
  168. content: [
  169. "=== SECTION: story_bible ===",
  170. "# 故事圣经",
  171. "",
  172. "=== SECTION: volume_outline ===",
  173. "# 卷纲",
  174. "",
  175. "=== SECTION: book_rules ===",
  176. "---",
  177. "version: \"1.0\"",
  178. "---",
  179. "",
  180. "=== SECTION: current_state ===",
  181. "# 当前状态",
  182. "",
  183. "=== SECTION: pending_hooks ===",
  184. "# 待回收伏笔",
  185. ].join("\n"),
  186. usage: ZERO_USAGE,
  187. });
  188. await agent.generateFoundation(
  189. book,
  190. undefined,
  191. "请把核心冲突收紧,并明确新空间不是旧案重演。",
  192. );
  193. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  194. expect(messages[0]?.content).toContain("上一轮审核反馈");
  195. expect(messages[0]?.content).toContain("请把核心冲突收紧");
  196. expect(messages[0]?.content).toContain("明确新空间不是旧案重演");
  197. });
  198. it("embeds reviewer feedback into fanfic foundation regeneration prompts", async () => {
  199. const agent = new ArchitectAgent({
  200. client: {
  201. provider: "openai",
  202. apiFormat: "chat",
  203. stream: false,
  204. defaults: {
  205. temperature: 0.7,
  206. maxTokens: 4096,
  207. thinkingBudget: 0, maxTokensCap: null,
  208. extra: {},
  209. },
  210. },
  211. model: "test-model",
  212. projectRoot: process.cwd(),
  213. });
  214. const book: BookConfig = {
  215. id: "fanfic-review-feedback-book",
  216. title: "三体:回声舱",
  217. platform: "tomato",
  218. genre: "other",
  219. status: "active",
  220. targetChapters: 60,
  221. chapterWordCount: 2200,
  222. language: "zh",
  223. createdAt: "2026-04-03T00:00:00.000Z",
  224. updatedAt: "2026-04-03T00:00:00.000Z",
  225. };
  226. const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  227. .mockResolvedValue({
  228. content: [
  229. "=== SECTION: story_bible ===",
  230. "# 故事圣经",
  231. "",
  232. "=== SECTION: volume_outline ===",
  233. "# 卷纲",
  234. "",
  235. "=== SECTION: book_rules ===",
  236. "---",
  237. "version: \"1.0\"",
  238. "---",
  239. "",
  240. "=== SECTION: current_state ===",
  241. "# 当前状态",
  242. "",
  243. "=== SECTION: pending_hooks ===",
  244. "# 待回收伏笔",
  245. ].join("\n"),
  246. usage: ZERO_USAGE,
  247. });
  248. await agent.generateFanficFoundation(
  249. book,
  250. "# 原作正典\n- 罗辑在面壁计划中留下了一处空档。",
  251. "canon",
  252. "请明确分岔点,并用原创冲突替代原作重走。",
  253. );
  254. const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
  255. expect(messages[0]?.content).toContain("上一轮审核反馈");
  256. expect(messages[0]?.content).toContain("请明确分岔点");
  257. expect(messages[0]?.content).toContain("原创冲突替代原作重走");
  258. });
  259. it("strips assistant-style trailing coda from the final pending hooks section", async () => {
  260. const agent = new ArchitectAgent({
  261. client: {
  262. provider: "openai",
  263. apiFormat: "chat",
  264. stream: false,
  265. defaults: {
  266. temperature: 0.7,
  267. maxTokens: 4096,
  268. thinkingBudget: 0, maxTokensCap: null,
  269. extra: {},
  270. },
  271. },
  272. model: "test-model",
  273. projectRoot: process.cwd(),
  274. });
  275. const book: BookConfig = {
  276. id: "zh-book",
  277. title: "雾港回灯",
  278. platform: "other",
  279. genre: "other",
  280. status: "active",
  281. targetChapters: 50,
  282. chapterWordCount: 2200,
  283. language: "zh",
  284. createdAt: "2026-03-24T00:00:00.000Z",
  285. updatedAt: "2026-03-24T00:00:00.000Z",
  286. };
  287. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  288. .mockResolvedValue({
  289. content: [
  290. "=== SECTION: story_bible ===",
  291. "# 故事圣经",
  292. "",
  293. "=== SECTION: volume_outline ===",
  294. "# 卷纲",
  295. "",
  296. "=== SECTION: book_rules ===",
  297. "---",
  298. "version: \"1.0\"",
  299. "---",
  300. "",
  301. "=== SECTION: current_state ===",
  302. "# 当前状态",
  303. "",
  304. "=== SECTION: pending_hooks ===",
  305. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  306. "| --- | --- | --- | --- | --- | --- | --- |",
  307. "| H01 | 1 | 主线 | 未开启 | 无 | 10章 | 主线核心钩子 |",
  308. "",
  309. "如果你愿意,我下一步可以继续为这本《雾港回灯》输出:",
  310. "1. 前10章逐章细纲",
  311. ].join("\n"),
  312. usage: ZERO_USAGE,
  313. });
  314. const result = await agent.generateFoundation(book);
  315. // Phase 7 + hotfixes 1/2: ledger renders extended columns — depends_on,
  316. // pays_off_in_arc, core_hook, half_life (empty when not specified), and
  317. // promoted (computed at architect time). This hook has no promotion rule
  318. // firing (core=否, no depends_on, in-volume payoff) so 升级=否.
  319. expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 无 | | 否 | | 否 | 主线核心钩子 |");
  320. expect(result.pendingHooks).not.toContain("如果你愿意");
  321. expect(result.pendingHooks).not.toContain("前10章逐章细纲");
  322. });
  323. it("normalizes architect pending hooks into runtime-compatible numeric progress columns", async () => {
  324. const agent = new ArchitectAgent({
  325. client: {
  326. provider: "openai",
  327. apiFormat: "chat",
  328. stream: false,
  329. defaults: {
  330. temperature: 0.7,
  331. maxTokens: 4096,
  332. thinkingBudget: 0, maxTokensCap: null,
  333. extra: {},
  334. },
  335. },
  336. model: "test-model",
  337. projectRoot: process.cwd(),
  338. });
  339. const book: BookConfig = {
  340. id: "zh-book",
  341. title: "凌晨三点的证词",
  342. platform: "tomato",
  343. genre: "urban",
  344. status: "active",
  345. targetChapters: 80,
  346. chapterWordCount: 2000,
  347. language: "zh",
  348. createdAt: "2026-03-25T00:00:00.000Z",
  349. updatedAt: "2026-03-25T00:00:00.000Z",
  350. };
  351. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  352. .mockResolvedValue({
  353. content: [
  354. "=== SECTION: story_bible ===",
  355. "# 故事圣经",
  356. "",
  357. "=== SECTION: volume_outline ===",
  358. "# 卷纲",
  359. "",
  360. "=== SECTION: book_rules ===",
  361. "---",
  362. "version: \"1.0\"",
  363. "---",
  364. "",
  365. "=== SECTION: current_state ===",
  366. "# 当前状态",
  367. "",
  368. "=== SECTION: pending_hooks ===",
  369. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  370. "| --- | --- | --- | --- | --- | --- | --- |",
  371. "| H13 | 22 | 舆情操盘 | 待推进 | 一家自媒体公司在多个旧案节点同步接单 | 51-60章 | 庄蔓出场后逐步揭露 |",
  372. ].join("\n"),
  373. usage: ZERO_USAGE,
  374. });
  375. const result = await agent.generateFoundation(book);
  376. expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 中程 | 无 | | 否 | | 否 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
  377. });
  378. it("accepts section labels with spacing and punctuation drift from non-strict models", async () => {
  379. const agent = new ArchitectAgent({
  380. client: {
  381. provider: "openai",
  382. apiFormat: "chat",
  383. stream: false,
  384. defaults: {
  385. temperature: 0.7,
  386. maxTokens: 4096,
  387. thinkingBudget: 0, maxTokensCap: null,
  388. extra: {},
  389. },
  390. },
  391. model: "test-model",
  392. projectRoot: process.cwd(),
  393. });
  394. const book: BookConfig = {
  395. id: "format-drift-book",
  396. title: "格式漂移测试",
  397. platform: "other",
  398. genre: "other",
  399. status: "active",
  400. targetChapters: 20,
  401. chapterWordCount: 2200,
  402. language: "zh",
  403. createdAt: "2026-04-01T00:00:00.000Z",
  404. updatedAt: "2026-04-01T00:00:00.000Z",
  405. };
  406. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  407. .mockResolvedValue({
  408. content: [
  409. "=== Section:Story Bible ===",
  410. "# 故事圣经",
  411. "",
  412. "=== section: Volume Outline ===",
  413. "# 卷纲",
  414. "",
  415. "=== SECTION: book-rules ===",
  416. "---",
  417. "version: \"1.0\"",
  418. "---",
  419. "",
  420. "=== SECTION : current state ===",
  421. "# 当前状态",
  422. "",
  423. "=== SECTION: pending hooks ===",
  424. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  425. "| --- | --- | --- | --- | --- | --- | --- |",
  426. "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
  427. ].join("\n"),
  428. usage: ZERO_USAGE,
  429. });
  430. const result = await agent.generateFoundation(book);
  431. expect(result.storyBible).toBe("# 故事圣经");
  432. expect(result.volumeOutline).toBe("# 卷纲");
  433. expect(result.bookRules).toContain("version: \"1.0\"");
  434. expect(result.currentState).toBe("# 当前状态");
  435. expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 中程 | 无 | | 否 | | 否 | 初始钩子 |");
  436. });
  437. it("throws when a required foundation section is missing", async () => {
  438. const agent = new ArchitectAgent({
  439. client: {
  440. provider: "openai",
  441. apiFormat: "chat",
  442. stream: false,
  443. defaults: {
  444. temperature: 0.7,
  445. maxTokens: 4096,
  446. thinkingBudget: 0, maxTokensCap: null,
  447. extra: {},
  448. },
  449. },
  450. model: "test-model",
  451. projectRoot: process.cwd(),
  452. });
  453. const book: BookConfig = {
  454. id: "broken-book",
  455. title: "Broken Book",
  456. platform: "other",
  457. genre: "other",
  458. status: "active",
  459. targetChapters: 20,
  460. chapterWordCount: 2200,
  461. language: "zh",
  462. createdAt: "2026-03-29T00:00:00.000Z",
  463. updatedAt: "2026-03-29T00:00:00.000Z",
  464. };
  465. vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  466. .mockResolvedValue({
  467. content: [
  468. "=== SECTION: story_bible ===",
  469. "# 故事圣经",
  470. "",
  471. "=== SECTION: volume_outline ===",
  472. "# 卷纲",
  473. "",
  474. "=== SECTION: current_state ===",
  475. "# 当前状态",
  476. "",
  477. "=== SECTION: pending_hooks ===",
  478. "# 伏笔池",
  479. ].join("\n"),
  480. usage: ZERO_USAGE,
  481. });
  482. await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
  483. });
  484. it("passes maxTokens 16384 when generating foundation", async () => {
  485. const agent = new ArchitectAgent({
  486. client: {
  487. provider: "openai",
  488. apiFormat: "chat",
  489. stream: false,
  490. defaults: {
  491. temperature: 0.7,
  492. maxTokens: 4096,
  493. thinkingBudget: 0, maxTokensCap: null,
  494. extra: {},
  495. },
  496. },
  497. model: "test-model",
  498. projectRoot: process.cwd(),
  499. });
  500. const book: BookConfig = {
  501. id: "max-tokens-book",
  502. title: "Max Tokens Book",
  503. platform: "other",
  504. genre: "other",
  505. status: "active",
  506. targetChapters: 20,
  507. chapterWordCount: 2200,
  508. language: "zh",
  509. createdAt: "2026-03-29T00:00:00.000Z",
  510. updatedAt: "2026-03-29T00:00:00.000Z",
  511. };
  512. const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  513. .mockResolvedValue({
  514. content: [
  515. "=== SECTION: story_bible ===",
  516. "# 故事圣经",
  517. "",
  518. "=== SECTION: volume_outline ===",
  519. "# 卷纲",
  520. "",
  521. "=== SECTION: book_rules ===",
  522. "---",
  523. "version: \"1.0\"",
  524. "---",
  525. "",
  526. "=== SECTION: current_state ===",
  527. "# 当前状态",
  528. "",
  529. "=== SECTION: pending_hooks ===",
  530. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  531. "| --- | --- | --- | --- | --- | --- | --- |",
  532. "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
  533. ].join("\n"),
  534. usage: ZERO_USAGE,
  535. });
  536. await agent.generateFoundation(book);
  537. expect(chatSpy).toHaveBeenCalledWith(
  538. expect.any(Array),
  539. expect.objectContaining({ temperature: 0.8, maxTokens: 20480 }),
  540. );
  541. });
  542. it("passes maxTokens 16384 when generating foundation from import", async () => {
  543. const agent = new ArchitectAgent({
  544. client: {
  545. provider: "openai",
  546. apiFormat: "chat",
  547. stream: false,
  548. defaults: {
  549. temperature: 0.7,
  550. maxTokens: 4096,
  551. thinkingBudget: 0, maxTokensCap: null,
  552. extra: {},
  553. },
  554. },
  555. model: "test-model",
  556. projectRoot: process.cwd(),
  557. });
  558. const book: BookConfig = {
  559. id: "import-max-tokens-book",
  560. title: "Import Max Tokens Book",
  561. platform: "other",
  562. genre: "other",
  563. status: "active",
  564. targetChapters: 20,
  565. chapterWordCount: 2200,
  566. language: "zh",
  567. createdAt: "2026-03-29T00:00:00.000Z",
  568. updatedAt: "2026-03-29T00:00:00.000Z",
  569. };
  570. const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  571. .mockResolvedValue({
  572. content: [
  573. "=== SECTION: story_bible ===",
  574. "# 故事圣经",
  575. "",
  576. "=== SECTION: volume_outline ===",
  577. "# 卷纲",
  578. "",
  579. "=== SECTION: book_rules ===",
  580. "---",
  581. "version: \"1.0\"",
  582. "---",
  583. "",
  584. "=== SECTION: current_state ===",
  585. "# 当前状态",
  586. "",
  587. "=== SECTION: pending_hooks ===",
  588. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  589. "| --- | --- | --- | --- | --- | --- | --- |",
  590. "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
  591. ].join("\n"),
  592. usage: ZERO_USAGE,
  593. });
  594. await agent.generateFoundationFromImport(book, "第一章正文");
  595. expect(chatSpy).toHaveBeenCalledWith(
  596. expect.any(Array),
  597. expect.objectContaining({ temperature: 0.5, maxTokens: 20480 }),
  598. );
  599. });
  600. it("passes maxTokens 16384 when generating fanfic foundation", async () => {
  601. const agent = new ArchitectAgent({
  602. client: {
  603. provider: "openai",
  604. apiFormat: "chat",
  605. stream: false,
  606. defaults: {
  607. temperature: 0.7,
  608. maxTokens: 4096,
  609. thinkingBudget: 0, maxTokensCap: null,
  610. extra: {},
  611. },
  612. },
  613. model: "test-model",
  614. projectRoot: process.cwd(),
  615. });
  616. const book: BookConfig = {
  617. id: "fanfic-max-tokens-book",
  618. title: "Fanfic Max Tokens Book",
  619. platform: "other",
  620. genre: "fanfic",
  621. status: "active",
  622. targetChapters: 20,
  623. chapterWordCount: 2200,
  624. language: "zh",
  625. createdAt: "2026-03-29T00:00:00.000Z",
  626. updatedAt: "2026-03-29T00:00:00.000Z",
  627. };
  628. const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
  629. .mockResolvedValue({
  630. content: [
  631. "=== SECTION: story_bible ===",
  632. "# 故事圣经",
  633. "",
  634. "=== SECTION: volume_outline ===",
  635. "# 卷纲",
  636. "",
  637. "=== SECTION: book_rules ===",
  638. "---",
  639. "version: \"1.0\"",
  640. "---",
  641. "",
  642. "=== SECTION: current_state ===",
  643. "# 当前状态",
  644. "",
  645. "=== SECTION: pending_hooks ===",
  646. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  647. "| --- | --- | --- | --- | --- | --- | --- |",
  648. "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
  649. ].join("\n"),
  650. usage: ZERO_USAGE,
  651. });
  652. await agent.generateFanficFoundation(book, "正典文本", "canon");
  653. expect(chatSpy).toHaveBeenCalledWith(
  654. expect.any(Array),
  655. expect.objectContaining({ temperature: 0.7, maxTokens: 20480 }),
  656. );
  657. });
  658. });