| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714 |
- import { afterEach, describe, expect, it, vi } from "vitest";
- import { ArchitectAgent } from "../agents/architect.js";
- import type { BookConfig } from "../models/book.js";
- const ZERO_USAGE = {
- promptTokens: 0,
- completionTokens: 0,
- totalTokens: 0,
- } as const;
- describe("ArchitectAgent", () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
- it("uses English prompts when generating foundation from imported English chapters", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-24T00:00:00.000Z",
- updatedAt: "2026-03-24T00:00:00.000Z",
- };
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# Story Bible",
- "",
- "=== SECTION: volume_outline ===",
- "# Volume Outline",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "# Book Rules",
- "",
- "=== SECTION: current_state ===",
- "# Current State",
- "",
- "=== SECTION: pending_hooks ===",
- "# Pending Hooks",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFoundationFromImport(
- book,
- "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
- );
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- expect(messages[0]?.content).toContain("MUST be written in English");
- expect(messages[1]?.content).toContain("Generate the complete foundation");
- expect(messages[1]?.content).not.toContain("请从中反向推导");
- });
- it("does not embed Chinese section headings in imported English foundation prompts", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "english-book",
- title: "English Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "en",
- createdAt: "2026-03-24T00:00:00.000Z",
- updatedAt: "2026-03-24T00:00:00.000Z",
- };
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# Story Bible",
- "",
- "=== SECTION: volume_outline ===",
- "# Volume Outline",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "# Book Rules",
- "",
- "=== SECTION: current_state ===",
- "# Current State",
- "",
- "=== SECTION: pending_hooks ===",
- "# Pending Hooks",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFoundationFromImport(
- book,
- "Chapter 1: Prelude\n\nA cold wind crossed the harbor.",
- );
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- // Phase 5: architect prompts describe the new prose sections. The English
- // import prompt must not slip Chinese section headers into the system text.
- expect(messages[0]?.content).toContain("story_frame");
- expect(messages[0]?.content).toContain("volume_map");
- expect(messages[0]?.content).not.toContain("## 01_世界观");
- expect(messages[0]?.content).not.toContain("## 叙事视角");
- });
- it("embeds reviewer feedback into original foundation regeneration prompts", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "review-feedback-book",
- title: "雾港回灯",
- platform: "tomato",
- genre: "urban",
- status: "active",
- targetChapters: 60,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-04-03T00:00:00.000Z",
- updatedAt: "2026-04-03T00:00:00.000Z",
- };
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "# 待回收伏笔",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFoundation(
- book,
- undefined,
- "请把核心冲突收紧,并明确新空间不是旧案重演。",
- );
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- expect(messages[0]?.content).toContain("上一轮审核反馈");
- expect(messages[0]?.content).toContain("请把核心冲突收紧");
- expect(messages[0]?.content).toContain("明确新空间不是旧案重演");
- });
- it("embeds reviewer feedback into fanfic foundation regeneration prompts", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "fanfic-review-feedback-book",
- title: "三体:回声舱",
- platform: "tomato",
- genre: "other",
- status: "active",
- targetChapters: 60,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-04-03T00:00:00.000Z",
- updatedAt: "2026-04-03T00:00:00.000Z",
- };
- const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "# 待回收伏笔",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFanficFoundation(
- book,
- "# 原作正典\n- 罗辑在面壁计划中留下了一处空档。",
- "canon",
- "请明确分岔点,并用原创冲突替代原作重走。",
- );
- const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
- expect(messages[0]?.content).toContain("上一轮审核反馈");
- expect(messages[0]?.content).toContain("请明确分岔点");
- expect(messages[0]?.content).toContain("原创冲突替代原作重走");
- });
- it("strips assistant-style trailing coda from the final pending hooks section", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "zh-book",
- title: "雾港回灯",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 50,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-24T00:00:00.000Z",
- updatedAt: "2026-03-24T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H01 | 1 | 主线 | 未开启 | 无 | 10章 | 主线核心钩子 |",
- "",
- "如果你愿意,我下一步可以继续为这本《雾港回灯》输出:",
- "1. 前10章逐章细纲",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- const result = await agent.generateFoundation(book);
- // Phase 7 + hotfixes 1/2: ledger renders extended columns — depends_on,
- // pays_off_in_arc, core_hook, half_life (empty when not specified), and
- // promoted (computed at architect time). This hook has no promotion rule
- // firing (core=否, no depends_on, in-volume payoff) so 升级=否.
- expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 中程 | 无 | | 否 | | 否 | 主线核心钩子 |");
- expect(result.pendingHooks).not.toContain("如果你愿意");
- expect(result.pendingHooks).not.toContain("前10章逐章细纲");
- });
- it("normalizes architect pending hooks into runtime-compatible numeric progress columns", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "zh-book",
- title: "凌晨三点的证词",
- platform: "tomato",
- genre: "urban",
- status: "active",
- targetChapters: 80,
- chapterWordCount: 2000,
- language: "zh",
- createdAt: "2026-03-25T00:00:00.000Z",
- updatedAt: "2026-03-25T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H13 | 22 | 舆情操盘 | 待推进 | 一家自媒体公司在多个旧案节点同步接单 | 51-60章 | 庄蔓出场后逐步揭露 |",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- const result = await agent.generateFoundation(book);
- expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 中程 | 无 | | 否 | | 否 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
- });
- it("accepts section labels with spacing and punctuation drift from non-strict models", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "format-drift-book",
- title: "格式漂移测试",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-04-01T00:00:00.000Z",
- updatedAt: "2026-04-01T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== Section:Story Bible ===",
- "# 故事圣经",
- "",
- "=== section: Volume Outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book-rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION : current state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- const result = await agent.generateFoundation(book);
- expect(result.storyBible).toBe("# 故事圣经");
- expect(result.volumeOutline).toBe("# 卷纲");
- expect(result.bookRules).toContain("version: \"1.0\"");
- expect(result.currentState).toBe("# 当前状态");
- expect(result.pendingHooks).toContain("| H01 | 1 | mystery | open | 0 | 10章 | 中程 | 无 | | 否 | | 否 | 初始钩子 |");
- });
- it("throws when a required foundation section is missing", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "broken-book",
- title: "Broken Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- };
- vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "# 伏笔池",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
- });
- it("passes maxTokens 16384 when generating foundation", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "max-tokens-book",
- title: "Max Tokens Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- };
- const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFoundation(book);
- expect(chatSpy).toHaveBeenCalledWith(
- expect.any(Array),
- expect.objectContaining({ temperature: 0.8, maxTokens: 20480 }),
- );
- });
- it("passes maxTokens 16384 when generating foundation from import", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "import-max-tokens-book",
- title: "Import Max Tokens Book",
- platform: "other",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- };
- const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFoundationFromImport(book, "第一章正文");
- expect(chatSpy).toHaveBeenCalledWith(
- expect.any(Array),
- expect.objectContaining({ temperature: 0.5, maxTokens: 20480 }),
- );
- });
- it("passes maxTokens 16384 when generating fanfic foundation", async () => {
- const agent = new ArchitectAgent({
- client: {
- provider: "openai",
- apiFormat: "chat",
- stream: false,
- defaults: {
- temperature: 0.7,
- maxTokens: 4096,
- thinkingBudget: 0, maxTokensCap: null,
- extra: {},
- },
- },
- model: "test-model",
- projectRoot: process.cwd(),
- });
- const book: BookConfig = {
- id: "fanfic-max-tokens-book",
- title: "Fanfic Max Tokens Book",
- platform: "other",
- genre: "fanfic",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 2200,
- language: "zh",
- createdAt: "2026-03-29T00:00:00.000Z",
- updatedAt: "2026-03-29T00:00:00.000Z",
- };
- const chatSpy = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
- .mockResolvedValue({
- content: [
- "=== SECTION: story_bible ===",
- "# 故事圣经",
- "",
- "=== SECTION: volume_outline ===",
- "# 卷纲",
- "",
- "=== SECTION: book_rules ===",
- "---",
- "version: \"1.0\"",
- "---",
- "",
- "=== SECTION: current_state ===",
- "# 当前状态",
- "",
- "=== SECTION: pending_hooks ===",
- "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
- "| --- | --- | --- | --- | --- | --- | --- |",
- "| H01 | 1 | mystery | open | 0 | 10章 | 初始钩子 |",
- ].join("\n"),
- usage: ZERO_USAGE,
- });
- await agent.generateFanficFoundation(book, "正典文本", "canon");
- expect(chatSpy).toHaveBeenCalledWith(
- expect.any(Array),
- expect.objectContaining({ temperature: 0.7, maxTokens: 20480 }),
- );
- });
- });
|