mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
fix(interaction): harden agent errors and file handling
This commit is contained in:
@@ -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)));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user