| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { StateManager } from "../state/manager.js";
- import {
- createSubAgentTool,
- createPatchChapterTextTool,
- createRenameEntityTool,
- createReviseChapterTool,
- createWriteTruthFileTool,
- } from "../agent/agent-tools.js";
- describe("agent deterministic writing tools", () => {
- let root: string;
- let state: StateManager;
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
- state = new StateManager(root);
- await state.saveBookConfig("harbor", {
- id: "harbor",
- title: "Harbor",
- platform: "tomato",
- genre: "other",
- status: "active",
- targetChapters: 20,
- chapterWordCount: 3000,
- createdAt: "2026-04-16T00:00:00.000Z",
- updatedAt: "2026-04-16T00:00:00.000Z",
- });
- await mkdir(join(state.bookDir("harbor"), "story", "runtime"), { recursive: true });
- await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
- await writeFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "# Story Bible\n\nLin Yue guards the jade seal.\n", "utf-8");
- await writeFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "# 第3章 风暴\n\nLin Yue kept the jade seal hidden.\n", "utf-8");
- await state.saveChapterIndex("harbor", [{
- number: 3,
- title: "风暴",
- status: "ready-for-review",
- wordCount: 120,
- createdAt: "2026-04-16T00:00:00.000Z",
- updatedAt: "2026-04-16T00:00:00.000Z",
- auditIssues: [],
- lengthWarnings: [],
- }]);
- });
- afterEach(async () => {
- await rm(root, { recursive: true, force: true });
- });
- it("writes truth files through the deterministic tool path", async () => {
- const tool = createWriteTruthFileTool({} as never, root, "harbor");
- const result = await tool.execute("tool-1", {
- fileName: "story_bible.md",
- content: "# Story Bible\n\nLin Yue now distrusts the guild.\n",
- });
- expect(result.content[0]?.type).toBe("text");
- await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
- .resolves.toContain("distrusts the guild");
- });
- it("routes chapter rewrite requests through reviseDraft with the requested mode", async () => {
- const pipeline = {
- reviseDraft: vi.fn(async () => ({
- chapterNumber: 3,
- wordCount: 1300,
- fixedIssues: [],
- applied: true,
- status: "ready-for-review" as const,
- })),
- };
- const tool = createReviseChapterTool(pipeline as never, "harbor");
- const result = await tool.execute("tool-2", {
- chapterNumber: 3,
- mode: "rewrite",
- });
- expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
- expect(result.content[0]?.type).toBe("text");
- });
- it("renames entities through the deterministic edit controller", async () => {
- const tool = createRenameEntityTool({} as never, root, "harbor");
- await tool.execute("tool-3", {
- oldValue: "Lin Yue",
- newValue: "Lin Yan",
- });
- await expect(readFile(join(state.bookDir("harbor"), "story", "story_bible.md"), "utf-8"))
- .resolves.toContain("Lin Yan");
- await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
- .resolves.toContain("Lin Yan");
- });
- it("patches chapter text through the deterministic edit controller", async () => {
- const tool = createPatchChapterTextTool({} as never, root, "harbor");
- await tool.execute("tool-4", {
- chapterNumber: 3,
- targetText: "jade seal hidden",
- replacementText: "jade seal locked beneath the altar",
- });
- await expect(readFile(join(state.bookDir("harbor"), "chapters", "0003_Storm.md"), "utf-8"))
- .resolves.toContain("locked beneath the altar");
- await expect(state.loadChapterIndex("harbor")).resolves.toEqual([
- expect.objectContaining({
- number: 3,
- status: "audit-failed",
- auditIssues: expect.arrayContaining([
- expect.stringContaining("Manual text edit requires review"),
- ]),
- }),
- ]);
- });
- it("requires an explicit title when the architect sub-agent creates a book", async () => {
- const pipeline = {
- initBook: vi.fn(async () => undefined),
- };
- const tool = createSubAgentTool(pipeline as never, null);
- const result = await tool.execute("tool-5", {
- agent: "architect",
- instruction: "写一本港风商战小说",
- });
- expect(result.content[0]?.type).toBe("text");
- if (result.content[0]?.type === "text") {
- expect(result.content[0].text).toContain("title is required");
- }
- expect(pipeline.initBook).not.toHaveBeenCalled();
- });
- it("passes the explicit architect title straight into initBook", async () => {
- const pipeline = {
- initBook: vi.fn(async () => undefined),
- };
- const tool = createSubAgentTool(pipeline as never, null);
- await tool.execute("tool-6", {
- agent: "architect",
- title: "夜港账本",
- instruction: "写一本港风商战小说",
- });
- expect(pipeline.initBook).toHaveBeenCalledWith(
- expect.objectContaining({
- title: "夜港账本",
- }),
- expect.objectContaining({
- externalContext: "写一本港风商战小说",
- }),
- );
- });
- it("passes chapterWordCount through the writer sub-agent", async () => {
- const pipeline = {
- writeNextChapter: vi.fn(async () => ({
- chapterNumber: 4,
- wordCount: 2600,
- })),
- };
- const tool = createSubAgentTool(pipeline as never, "harbor");
- await tool.execute("tool-7", {
- agent: "writer",
- bookId: "harbor",
- chapterWordCount: 2600,
- instruction: "继续写,控制在 2600 字",
- } as any);
- expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
- });
- it("prefers explicit reviser mode over instruction guessing", async () => {
- const pipeline = {
- reviseDraft: vi.fn(async () => ({
- chapterNumber: 3,
- wordCount: 120,
- fixedIssues: [],
- applied: true,
- status: "ready-for-review" as const,
- })),
- };
- const tool = createSubAgentTool(pipeline as never, "harbor");
- await tool.execute("tool-8", {
- agent: "reviser",
- bookId: "harbor",
- chapterNumber: 3,
- mode: "spot-fix",
- instruction: "重写第3章",
- } as any);
- expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "spot-fix");
- });
- it("uses explicit exporter params instead of guessing from instruction", async () => {
- const pipeline = {};
- const tool = createSubAgentTool(pipeline as never, "harbor", root);
- const result = await tool.execute("tool-9", {
- agent: "exporter",
- bookId: "harbor",
- format: "md",
- approvedOnly: false,
- instruction: "导出成 epub",
- } as any);
- expect(result.content[0]?.type).toBe("text");
- if (result.content[0]?.type === "text") {
- expect(result.content[0].text).toContain(".md");
- }
- });
- });
|