agent-tools.test.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import { StateManager } from "../state/manager.js";
  6. import {
  7. createSubAgentTool,
  8. createPatchChapterTextTool,
  9. createRenameEntityTool,
  10. createReviseChapterTool,
  11. createWriteTruthFileTool,
  12. } from "../agent/agent-tools.js";
  13. describe("agent deterministic writing tools", () => {
  14. let root: string;
  15. let state: StateManager;
  16. beforeEach(async () => {
  17. root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
  18. state = new StateManager(root);
  19. await state.saveBookConfig("harbor", {
  20. id: "harbor",
  21. title: "Harbor",
  22. platform: "tomato",
  23. genre: "other",
  24. status: "active",
  25. targetChapters: 20,
  26. chapterWordCount: 3000,
  27. createdAt: "2026-04-16T00:00:00.000Z",
  28. updatedAt: "2026-04-16T00:00:00.000Z",
  29. });
  30. await mkdir(join(state.bookDir("harbor"), "story", "runtime"), { recursive: true });
  31. await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
  32. await writeFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "# Story Bible\n\nLin Yue guards the jade seal.\n", "utf-8");
  33. await writeFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "# 第3章 风暴\n\nLin Yue kept the jade seal hidden.\n", "utf-8");
  34. await state.saveChapterIndex("harbor", [{
  35. number: 3,
  36. title: "风暴",
  37. status: "ready-for-review",
  38. wordCount: 120,
  39. createdAt: "2026-04-16T00:00:00.000Z",
  40. updatedAt: "2026-04-16T00:00:00.000Z",
  41. auditIssues: [],
  42. lengthWarnings: [],
  43. }]);
  44. });
  45. afterEach(async () => {
  46. await rm(root, { recursive: true, force: true });
  47. });
  48. it("writes truth files through the deterministic tool path", async () => {
  49. const tool = createWriteTruthFileTool({} as never, root, "harbor");
  50. const result = await tool.execute("tool-1", {
  51. fileName: "story_bible.md",
  52. content: "# Story Bible\n\nLin Yue now distrusts the guild.\n",
  53. });
  54. expect(result.content[0]?.type).toBe("text");
  55. await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
  56. .resolves.toContain("distrusts the guild");
  57. });
  58. it("routes chapter rewrite requests through reviseDraft with the requested mode", async () => {
  59. const pipeline = {
  60. reviseDraft: vi.fn(async () => ({
  61. chapterNumber: 3,
  62. wordCount: 1300,
  63. fixedIssues: [],
  64. applied: true,
  65. status: "ready-for-review" as const,
  66. })),
  67. };
  68. const tool = createReviseChapterTool(pipeline as never, "harbor");
  69. const result = await tool.execute("tool-2", {
  70. chapterNumber: 3,
  71. mode: "rewrite",
  72. });
  73. expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
  74. expect(result.content[0]?.type).toBe("text");
  75. });
  76. it("renames entities through the deterministic edit controller", async () => {
  77. const tool = createRenameEntityTool({} as never, root, "harbor");
  78. await tool.execute("tool-3", {
  79. oldValue: "Lin Yue",
  80. newValue: "Lin Yan",
  81. });
  82. await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
  83. .resolves.toContain("Lin Yan");
  84. await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
  85. .resolves.toContain("Lin Yan");
  86. });
  87. it("patches chapter text through the deterministic edit controller", async () => {
  88. const tool = createPatchChapterTextTool({} as never, root, "harbor");
  89. await tool.execute("tool-4", {
  90. chapterNumber: 3,
  91. targetText: "jade seal hidden",
  92. replacementText: "jade seal locked beneath the altar",
  93. });
  94. await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
  95. .resolves.toContain("locked beneath the altar");
  96. await expect(state.loadChapterIndex("harbor")).resolves.toEqual([
  97. expect.objectContaining({
  98. number: 3,
  99. status: "audit-failed",
  100. auditIssues: expect.arrayContaining([
  101. expect.stringContaining("Manual text edit requires review"),
  102. ]),
  103. }),
  104. ]);
  105. });
  106. it("requires an explicit title when the architect sub-agent creates a book", async () => {
  107. const pipeline = {
  108. initBook: vi.fn(async () => undefined),
  109. };
  110. const tool = createSubAgentTool(pipeline as never, null);
  111. const result = await tool.execute("tool-5", {
  112. agent: "architect",
  113. instruction: "写一本港风商战小说",
  114. });
  115. expect(result.content[0]?.type).toBe("text");
  116. if (result.content[0]?.type === "text") {
  117. expect(result.content[0].text).toContain("title is required");
  118. }
  119. expect(pipeline.initBook).not.toHaveBeenCalled();
  120. });
  121. it("passes the explicit architect title straight into initBook", async () => {
  122. const pipeline = {
  123. initBook: vi.fn(async () => undefined),
  124. };
  125. const tool = createSubAgentTool(pipeline as never, null);
  126. await tool.execute("tool-6", {
  127. agent: "architect",
  128. title: "夜港账本",
  129. instruction: "写一本港风商战小说",
  130. });
  131. expect(pipeline.initBook).toHaveBeenCalledWith(
  132. expect.objectContaining({
  133. title: "夜港账本",
  134. }),
  135. expect.objectContaining({
  136. externalContext: "写一本港风商战小说",
  137. }),
  138. );
  139. });
  140. it("passes chapterWordCount through the writer sub-agent", async () => {
  141. const pipeline = {
  142. writeNextChapter: vi.fn(async () => ({
  143. chapterNumber: 4,
  144. wordCount: 2600,
  145. })),
  146. };
  147. const tool = createSubAgentTool(pipeline as never, "harbor");
  148. await tool.execute("tool-7", {
  149. agent: "writer",
  150. bookId: "harbor",
  151. chapterWordCount: 2600,
  152. instruction: "继续写,控制在 2600 字",
  153. } as any);
  154. expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
  155. });
  156. it("prefers explicit reviser mode over instruction guessing", async () => {
  157. const pipeline = {
  158. reviseDraft: vi.fn(async () => ({
  159. chapterNumber: 3,
  160. wordCount: 120,
  161. fixedIssues: [],
  162. applied: true,
  163. status: "ready-for-review" as const,
  164. })),
  165. };
  166. const tool = createSubAgentTool(pipeline as never, "harbor");
  167. await tool.execute("tool-8", {
  168. agent: "reviser",
  169. bookId: "harbor",
  170. chapterNumber: 3,
  171. mode: "spot-fix",
  172. instruction: "重写第3章",
  173. } as any);
  174. expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "spot-fix");
  175. });
  176. it("uses explicit exporter params instead of guessing from instruction", async () => {
  177. const pipeline = {};
  178. const tool = createSubAgentTool(pipeline as never, "harbor", root);
  179. const result = await tool.execute("tool-9", {
  180. agent: "exporter",
  181. bookId: "harbor",
  182. format: "md",
  183. approvedOnly: false,
  184. instruction: "导出成 epub",
  185. } as any);
  186. expect(result.content[0]?.type).toBe("text");
  187. if (result.content[0]?.type === "text") {
  188. expect(result.content[0].text).toContain(".md");
  189. }
  190. });
  191. });