From fbbf1086ac3d3b2284d2b9b787aa9cfca51b25d4 Mon Sep 17 00:00:00 2001 From: Ma Date: Mon, 13 Apr 2026 09:35:58 +0800 Subject: [PATCH] fix(interaction): harden agent errors and file handling --- .../src/__tests__/edit-controller.test.ts | 22 ++++++++++++++- .../src/__tests__/interaction-tools.test.ts | 18 +++++++++++- .../core/src/interaction/edit-controller.ts | 28 +++++++++++++++++-- .../core/src/interaction/project-tools.ts | 23 +++++++++++---- packages/studio/src/api/server.test.ts | 21 ++++++++++++++ packages/studio/src/api/server.ts | 7 ++++- 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/core/src/__tests__/edit-controller.test.ts b/packages/core/src/__tests__/edit-controller.test.ts index 6c9f1393..ba9fa1c0 100644 --- a/packages/core/src/__tests__/edit-controller.test.ts +++ b/packages/core/src/__tests__/edit-controller.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; 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 { classifyTruthAuthority, @@ -167,4 +167,24 @@ describe("edit controller", () => { expect(savedIndex[0]?.auditIssues.at(-1)).toContain("Manual text edit requires review"); 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); + }); }); diff --git a/packages/core/src/__tests__/interaction-tools.test.ts b/packages/core/src/__tests__/interaction-tools.test.ts index 48fba59c..63967cfb 100644 --- a/packages/core/src/__tests__/interaction-tools.test.ts +++ b/packages/core/src/__tests__/interaction-tools.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeAll, describe, expect, it, vi } from "vitest"; 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; @@ -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); + }); }); diff --git a/packages/core/src/interaction/edit-controller.ts b/packages/core/src/interaction/edit-controller.ts index 1db8efce..cdc2bef3 100644 --- a/packages/core/src/interaction/edit-controller.ts +++ b/packages/core/src/interaction/edit-controller.ts @@ -62,6 +62,13 @@ export interface ExecutedEditTransaction { 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 { switch (request.kind) { case "entity-rename": @@ -116,7 +123,12 @@ function escapeRegExp(text: string): string { } async function collectEditableFiles(dir: string): Promise> { - 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 fullPath = join(dir, entry.name); if (entry.isDirectory()) { @@ -169,7 +181,12 @@ async function executeChapterLocalEdit( const root = deps.bookDir(request.bookId); const chaptersDir = join(root, "chapters"); 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")); if (!chapterFile) { @@ -188,7 +205,12 @@ async function executeChapterLocalEdit( await writeFile(chapterPath, nextContent, "utf-8"); 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}.`)); await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined))); diff --git a/packages/core/src/interaction/project-tools.ts b/packages/core/src/interaction/project-tools.ts index f30f0169..7df6250a 100644 --- a/packages/core/src/interaction/project-tools.ts +++ b/packages/core/src/interaction/project-tools.ts @@ -182,6 +182,20 @@ function buildCreationExternalContext(input: { return sections.join("\n\n"); } +export function buildChapterFileLookup(files: ReadonlyArray): ReadonlyMap { + const lookup = new Map(); + 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: { readonly format?: "txt" | "md" | "epub"; readonly approvedOnly?: boolean; @@ -202,6 +216,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: { const chaptersDir = join(bookDir, "chapters"); const projectRoot = dirname(dirname(bookDir)); const outputPath = options.outputPath ?? join(projectRoot, `${bookId}_export.${format}`); + const chapterFiles = buildChapterFileLookup(await readdir(chaptersDir)); if (format === "epub") { const sections: string[] = [ @@ -211,9 +226,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: { ]; 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) { continue; } @@ -234,9 +247,7 @@ async function exportBookToPath(state: StateLike, bookId: string, options: { const parts: string[] = []; parts.push(format === "md" ? `# ${book.title}\n\n---\n` : `${book.title}\n\n`); 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) { continue; } diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index 0de3654b..0d52f8c5 100644 --- a/packages/studio/src/api/server.test.ts +++ b/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 () => { loadProjectSessionMock.mockResolvedValue({ sessionId: "session-2", diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index c8a672bf..62b44a59 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -599,7 +599,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) { } catch (e) { const msg = e instanceof Error ? e.message : String(e); broadcast("agent:error", { instruction, activeBookId, error: msg }); - return c.json({ response: msg }); + return c.json({ + error: { + code: "INTERACTION_ERROR", + message: msg, + }, + }, 500); } });