Просмотр исходного кода

fix(interaction): harden agent errors and file handling

Ma 7 месяцев назад
Родитель
Сommit
fbbf1086ac

+ 21 - 1
packages/core/src/__tests__/edit-controller.test.ts

@@ -1,7 +1,7 @@
 import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
 import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
-import { beforeAll, describe, expect, it } from "vitest";
+import { beforeAll, describe, expect, it, vi } from "vitest";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import {
 import {
   classifyTruthAuthority,
   classifyTruthAuthority,
@@ -167,4 +167,24 @@ describe("edit controller", () => {
     expect(savedIndex[0]?.auditIssues.at(-1)).toContain("Manual text edit requires review");
     expect(savedIndex[0]?.auditIssues.at(-1)).toContain("Manual text edit requires review");
     expect(result.reviewRequired).toBe(true);
     expect(result.reviewRequired).toBe(true);
   });
   });
+
+  it("does not swallow unexpected filesystem errors while collecting editable files", async () => {
+    const invalidRoot = join(projectRoot, "invalid-root.txt");
+    await writeFile(invalidRoot, "not a directory", "utf-8");
+
+    await expect(executeEditTransaction(
+      {
+        bookDir: () => invalidRoot,
+        loadChapterIndex: async () => [],
+        saveChapterIndex: async () => undefined,
+      },
+      {
+        kind: "entity-rename",
+        bookId: "harbor",
+        entityType: "protagonist",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+    )).rejects.toThrow(/not a directory|ENOTDIR/i);
+  });
 });
 });

+ 17 - 1
packages/core/src/__tests__/interaction-tools.test.ts

@@ -3,7 +3,10 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
 import { beforeAll, describe, expect, it, vi } from "vitest";
 import { beforeAll, describe, expect, it, vi } from "vitest";
 import { createLogger, type LogSink } from "../index.js";
 import { createLogger, type LogSink } from "../index.js";
-import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+import {
+  buildChapterFileLookup,
+  createInteractionToolsFromDeps,
+} from "../interaction/project-tools.js";
 
 
 let projectRoot: string;
 let projectRoot: string;
 
 
@@ -250,4 +253,17 @@ describe("interaction tools", () => {
       }),
       }),
     );
     );
   });
   });
+
+  it("builds a reusable chapter lookup from a single directory listing", () => {
+    const lookup = buildChapterFileLookup([
+      "0001_First.md",
+      "0002_Second.md",
+      "notes.txt",
+      "0002_Second.backup",
+    ]);
+
+    expect(lookup.get(1)).toBe("0001_First.md");
+    expect(lookup.get(2)).toBe("0002_Second.md");
+    expect(lookup.size).toBe(2);
+  });
 });
 });

+ 25 - 3
packages/core/src/interaction/edit-controller.ts

@@ -62,6 +62,13 @@ export interface ExecutedEditTransaction {
   readonly summary: string;
   readonly summary: string;
 }
 }
 
 
+function isMissingDirectoryError(error: unknown): boolean {
+  return typeof error === "object"
+    && error !== null
+    && "code" in error
+    && (error as { code?: unknown }).code === "ENOENT";
+}
+
 export function planEditTransaction(request: EditRequest): PlannedEditTransaction {
 export function planEditTransaction(request: EditRequest): PlannedEditTransaction {
   switch (request.kind) {
   switch (request.kind) {
     case "entity-rename":
     case "entity-rename":
@@ -116,7 +123,12 @@ function escapeRegExp(text: string): string {
 }
 }
 
 
 async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>> {
 async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>> {
-  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
+  const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  });
   const files = await Promise.all(entries.map(async (entry) => {
   const files = await Promise.all(entries.map(async (entry) => {
     const fullPath = join(dir, entry.name);
     const fullPath = join(dir, entry.name);
     if (entry.isDirectory()) {
     if (entry.isDirectory()) {
@@ -169,7 +181,12 @@ async function executeChapterLocalEdit(
   const root = deps.bookDir(request.bookId);
   const root = deps.bookDir(request.bookId);
   const chaptersDir = join(root, "chapters");
   const chaptersDir = join(root, "chapters");
   const paddedChapter = String(request.chapterNumber).padStart(4, "0");
   const paddedChapter = String(request.chapterNumber).padStart(4, "0");
-  const chapterFile = (await readdir(chaptersDir).catch(() => []))
+  const chapterFile = (await readdir(chaptersDir).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  }))
     .find((file) => file.startsWith(`${paddedChapter}_`) && file.endsWith(".md"));
     .find((file) => file.startsWith(`${paddedChapter}_`) && file.endsWith(".md"));
 
 
   if (!chapterFile) {
   if (!chapterFile) {
@@ -188,7 +205,12 @@ async function executeChapterLocalEdit(
   await writeFile(chapterPath, nextContent, "utf-8");
   await writeFile(chapterPath, nextContent, "utf-8");
 
 
   const runtimeDir = join(root, "story", "runtime");
   const runtimeDir = join(root, "story", "runtime");
-  const runtimeFiles = (await readdir(runtimeDir).catch(() => []))
+  const runtimeFiles = (await readdir(runtimeDir).catch((error) => {
+    if (isMissingDirectoryError(error)) {
+      return [];
+    }
+    throw error;
+  }))
     .filter((file) => file.startsWith(`chapter-${paddedChapter}.`));
     .filter((file) => file.startsWith(`chapter-${paddedChapter}.`));
   await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined)));
   await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined)));
 
 

+ 17 - 6
packages/core/src/interaction/project-tools.ts

@@ -182,6 +182,20 @@ function buildCreationExternalContext(input: {
   return sections.join("\n\n");
   return sections.join("\n\n");
 }
 }
 
 
+export function buildChapterFileLookup(files: ReadonlyArray<string>): ReadonlyMap<number, string> {
+  const lookup = new Map<number, string>();
+  for (const file of files) {
+    if (!file.endsWith(".md") || !/^\d{4}/.test(file)) {
+      continue;
+    }
+    const chapterNumber = parseInt(file.slice(0, 4), 10);
+    if (!lookup.has(chapterNumber)) {
+      lookup.set(chapterNumber, file);
+    }
+  }
+  return lookup;
+}
+
 async function exportBookToPath(state: StateLike, bookId: string, options: {
 async function exportBookToPath(state: StateLike, bookId: string, options: {
   readonly format?: "txt" | "md" | "epub";
   readonly format?: "txt" | "md" | "epub";
   readonly approvedOnly?: boolean;
   readonly approvedOnly?: boolean;
@@ -202,6 +216,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: {
   const chaptersDir = join(bookDir, "chapters");
   const chaptersDir = join(bookDir, "chapters");
   const projectRoot = dirname(dirname(bookDir));
   const projectRoot = dirname(dirname(bookDir));
   const outputPath = options.outputPath ?? join(projectRoot, `${bookId}_export.${format}`);
   const outputPath = options.outputPath ?? join(projectRoot, `${bookId}_export.${format}`);
+  const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir));
 
 
   if (format === "epub") {
   if (format === "epub") {
     const sections: string[] = [
     const sections: string[] = [
@@ -211,9 +226,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: {
     ];
     ];
 
 
     for (const chapter of chapters) {
     for (const chapter of chapters) {
-      const padded = String(chapter.number).padStart(4, "0");
-      const files = await readdir(chaptersDir);
-      const match = files.find((file) => file.startsWith(padded) && file.endsWith(".md"));
+      const match = chapterFiles.get(chapter.number);
       if (!match) {
       if (!match) {
         continue;
         continue;
       }
       }
@@ -234,9 +247,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: {
     const parts: string[] = [];
     const parts: string[] = [];
     parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`);
     parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`);
     for (const chapter of chapters) {
     for (const chapter of chapters) {
-      const padded = String(chapter.number).padStart(4, "0");
-      const files = await readdir(chaptersDir);
-      const match = files.find((file) => file.startsWith(padded) && file.endsWith(".md"));
+      const match = chapterFiles.get(chapter.number);
       if (!match) {
       if (!match) {
         continue;
         continue;
       }
       }

+ 21 - 0
packages/studio/src/api/server.test.ts

@@ -664,6 +664,27 @@ describe("createStudioServer daemon lifecycle", () => {
     }));
     }));
   });
   });
 
 
+  it("returns 500 with an error payload when the shared agent execution fails", async () => {
+    processProjectInteractionInputMock.mockRejectedValueOnce(new Error("boom"));
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(500);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "INTERACTION_ERROR",
+        message: "boom",
+      },
+    });
+  });
+
   it("returns the shared interaction session state", async () => {
   it("returns the shared interaction session state", async () => {
     loadProjectSessionMock.mockResolvedValue({
     loadProjectSessionMock.mockResolvedValue({
       sessionId: "session-2",
       sessionId: "session-2",

+ 6 - 1
packages/studio/src/api/server.ts

@@ -599,7 +599,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     } catch (e) {
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e);
       const msg = e instanceof Error ? e.message : String(e);
       broadcast("agent:error", { instruction, activeBookId, error: msg });
       broadcast("agent:error", { instruction, activeBookId, error: msg });
-      return c.json({ response: msg });
+      return c.json({
+        error: {
+          code: "INTERACTION_ERROR",
+          message: msg,
+        },
+      }, 500);
     }
     }
   });
   });