architect.test.ts 21 KB

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