fix(interaction): harden agent errors and file handling

This commit is contained in:
Ma
2026-04-13 09:35:58 +08:00
parent 1edd52a3e0
commit fbbf1086ac
6 changed files with 107 additions and 12 deletions
@@ -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);
});
});
@@ -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);
});
});
@@ -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<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 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)));
+17 -6
View File
@@ -182,6 +182,20 @@ function buildCreationExternalContext(input: {
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: {
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;
}
+21
View File
@@ -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",
+6 -1
View File
@@ -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);
}
});