feat: add readRaw method to filesystem backend protocol

This commit is contained in:
Tat Dat Duong
2025-11-24 23:45:43 +01:00
parent dc1fa13bbc
commit 73445c2b83
12 changed files with 381 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"deepagents": minor
---
Add readRaw method to filesystem backend protocol
+5 -9
View File
@@ -65,16 +65,12 @@ export const agent = createDeepAgent({
async function main() {
const threadId = uuidv4();
const message = new HumanMessage(
"Research the latest trends in AI agents for 2025",
);
await agent.invoke(
{
messages: [
new HumanMessage("Research the latest trends in AI agents for 2025"),
],
},
{
recursionLimit: 50,
configurable: { thread_id: threadId },
},
{ messages: [message] },
{ recursionLimit: 50, configurable: { thread_id: threadId } },
);
const threadId2 = uuidv4();
+1
View File
@@ -202,6 +202,7 @@ export const agent = createDeepAgent({
model: "claude-sonnet-4-20250514",
temperature: 0,
}),
tools: [internetSearch],
systemPrompt: researchInstructions,
subagents: [critiqueSubAgent, researchSubAgent],
+2 -2
View File
@@ -52,9 +52,9 @@
"@changesets/cli": "^2.29.7",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.19.0",
"@langchain/langgraph-checkpoint": "^0.0.13",
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/openai": "^1.0.0",
"@langchain/tavily": "^0.1.4",
"@langchain/tavily": "^1.0.0",
"@tsconfig/recommended": "^1.0.10",
"@types/micromatch": "^4.0.10",
"@types/node": "^22.13.5",
+10 -26
View File
@@ -40,14 +40,14 @@ importers:
specifier: ^9.19.0
version: 9.38.0
'@langchain/langgraph-checkpoint':
specifier: ^0.0.13
version: 0.0.13(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))
specifier: ^1.0.0
version: 1.0.0(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))
'@langchain/openai':
specifier: ^1.0.0
version: 1.0.0(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))
'@langchain/tavily':
specifier: ^0.1.4
version: 0.1.5(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))
specifier: ^1.0.0
version: 1.0.0(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))
'@tsconfig/recommended':
specifier: ^1.0.10
version: 1.0.11
@@ -593,12 +593,6 @@ packages:
resolution: {integrity: sha512-6mOn4bZyO6XT0GGrEijRtMVrmYJGZ8y1BcwyTPDptFz38lP0CEzrKEYB++h+u3TEcAd3eO25l1aGw/zVlVgw2Q==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint@0.0.13':
resolution: {integrity: sha512-amdmBcNT8a9xP2VwcEWxqArng4gtRDcnVyVI4DsQIo1Aaz8e8+hH17zSwrUF3pt1pIYztngIfYnBOim31mtKMg==}
engines: {node: '>=18'}
peerDependencies:
'@langchain/core': '>=0.2.31 <0.4.0'
'@langchain/langgraph-checkpoint@1.0.0':
resolution: {integrity: sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==}
engines: {node: '>=18'}
@@ -636,11 +630,11 @@ packages:
peerDependencies:
'@langchain/core': ^1.0.0
'@langchain/tavily@0.1.5':
resolution: {integrity: sha512-6C8hb3d6YjlGJPdPFfaNlrUS0h6blQx4cRzCKeDdCvixcghzV8llAmuzvJSmAyALprFfvKtj+1NJb4ttj1iZ/A==}
engines: {node: '>=18'}
'@langchain/tavily@1.0.0':
resolution: {integrity: sha512-kE3E1bTYtp4km7YLQM/lKOZW2yANFNsg4Fk33j11uAGcUGKN2skKzpEe4qgF34wRxMi4G4r7Qmpji2j+v24IaA==}
engines: {node: '>=20'}
peerDependencies:
'@langchain/core': '>=0.3.58 <0.4.0'
'@langchain/core': ^1.0.0
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@@ -2477,9 +2471,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.1.12:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
@@ -2931,11 +2922,6 @@ snapshots:
- '@opentelemetry/sdk-trace-base'
- openai
'@langchain/langgraph-checkpoint@0.0.13(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))':
dependencies:
'@langchain/core': 1.0.2(openai@6.7.0(zod@4.1.12))
uuid: 10.0.0
'@langchain/langgraph-checkpoint@1.0.0(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))':
dependencies:
'@langchain/core': 1.0.2(openai@6.7.0(zod@4.1.12))
@@ -2969,10 +2955,10 @@ snapshots:
transitivePeerDependencies:
- ws
'@langchain/tavily@0.1.5(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))':
'@langchain/tavily@1.0.0(@langchain/core@1.0.2(openai@6.7.0(zod@4.1.12)))':
dependencies:
'@langchain/core': 1.0.2(openai@6.7.0(zod@4.1.12))
zod: 3.25.76
zod: 4.1.12
'@manypkg/find-root@1.1.0':
dependencies:
@@ -4960,6 +4946,4 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.25.76: {}
zod@4.1.12: {}
+12
View File
@@ -5,6 +5,7 @@
import type {
BackendProtocol,
EditResult,
FileData,
FileInfo,
GrepMatch,
WriteResult,
@@ -128,6 +129,17 @@ export class CompositeBackend implements BackendProtocol {
return await backend.read(strippedKey, offset, limit);
}
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
async readRaw(filePath: string): Promise<FileData> {
const [backend, strippedKey] = this.getBackendAndKey(filePath);
return await backend.readRaw(strippedKey);
}
/**
* Structured search results or error string for invalid input.
*/
+41
View File
@@ -17,6 +17,7 @@ import micromatch from "micromatch";
import type {
BackendProtocol,
EditResult,
FileData,
FileInfo,
GrepMatch,
WriteResult,
@@ -241,6 +242,46 @@ export class FilesystemBackend implements BackendProtocol {
}
}
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
async readRaw(filePath: string): Promise<FileData> {
const resolvedPath = this.resolvePath(filePath);
let content: string;
let stat: fsSync.Stats;
if (SUPPORTS_NOFOLLOW) {
stat = await fs.stat(resolvedPath);
if (!stat.isFile()) throw new Error(`File '${filePath}' not found`);
const fd = await fs.open(
resolvedPath,
fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW,
);
try {
content = await fd.readFile({ encoding: "utf-8" });
} finally {
await fd.close();
}
} else {
stat = await fs.lstat(resolvedPath);
if (stat.isSymbolicLink()) {
throw new Error(`Symlinks are not allowed: ${filePath}`);
}
if (!stat.isFile()) throw new Error(`File '${filePath}' not found`);
content = await fs.readFile(resolvedPath, "utf-8");
}
return {
content: content.split("\n"),
created_at: stat.ctime.toISOString(),
modified_at: stat.mtime.toISOString(),
};
}
/**
* Create a new file with content.
* Returns WriteResult. External storage sets filesUpdate=null.
+8
View File
@@ -128,6 +128,14 @@ export interface BackendProtocol {
limit?: number,
): string | Promise<string>;
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
readRaw(filePath: string): FileData | Promise<FileData>;
/**
* Structured search results or error string for invalid input.
*
+14
View File
@@ -124,6 +124,20 @@ export class StateBackend implements BackendProtocol {
return formatReadResponse(fileData, offset, limit);
}
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
readRaw(filePath: string): FileData {
const files = this.getFiles();
const fileData = files[filePath];
if (!fileData) throw new Error(`File '${filePath}' not found`);
return fileData;
}
/**
* Create a new file with content.
* Returns WriteResult with filesUpdate to update LangGraph state.
+16 -9
View File
@@ -238,22 +238,29 @@ export class StoreBackend implements BackendProtocol {
offset: number = 0,
limit: number = 2000,
): Promise<string> {
const store = this.getStore();
const namespace = this.getNamespace();
const item = await store.get(namespace, filePath);
if (!item) {
return `Error: File '${filePath}' not found`;
}
try {
const fileData = this.convertStoreItemToFileData(item);
const fileData = await this.readRaw(filePath);
return formatReadResponse(fileData, offset, limit);
} catch (e: any) {
return `Error: ${e.message}`;
}
}
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
async readRaw(filePath: string): Promise<FileData> {
const store = this.getStore();
const namespace = this.getNamespace();
const item = await store.get(namespace, filePath);
if (!item) throw new Error(`File '${filePath}' not found`);
return this.convertStoreItemToFileData(item);
}
/**
* Create a new file with content.
* Returns WriteResult. External storage sets filesUpdate=null.
+111 -38
View File
@@ -108,8 +108,12 @@ export const GREP_TOOL_DESCRIPTION =
*/
function createLsTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -155,8 +159,12 @@ function createLsTool(
*/
function createReadFileTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -194,8 +202,12 @@ function createReadFileTool(
*/
function createWriteFileTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription, events } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -204,32 +216,40 @@ function createWriteFileTool(
};
const resolvedBackend = getBackend(backend, stateAndStore);
const { file_path, content } = input;
const result = await awaitIfPromise(
resolvedBackend.write(file_path, content),
);
const result = await resolvedBackend.write(file_path, content);
if (result.error) {
return result.error;
}
const resolved =
(await events?.onWrite?.(file_path, resolvedBackend)) ?? undefined;
const metadata = await (async () => {
if (resolved?.kind === "metadata") return resolved.data;
if (resolved?.kind === "raw-contents") {
const content = await resolvedBackend.readRaw(file_path);
return { ...content };
}
return undefined;
})();
// If filesUpdate is present, return Command to update state
const message = new ToolMessage({
content: `Successfully wrote to '${file_path}'`,
tool_call_id: config.toolCall?.id as string,
name: "write_file",
metadata,
});
if (result.filesUpdate) {
return new Command({
update: {
files: result.filesUpdate,
messages: [
new ToolMessage({
content: `Successfully wrote to '${file_path}'`,
tool_call_id: config.toolCall?.id as string,
name: "write_file",
}),
],
},
update: { files: result.filesUpdate, messages: [message] },
});
}
// External storage (filesUpdate is null)
return `Successfully wrote to '${file_path}'`;
return message;
},
{
name: "write_file",
@@ -247,8 +267,12 @@ function createWriteFileTool(
*/
function createEditFileTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription, events } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -265,21 +289,30 @@ function createEditFileTool(
return result.error;
}
const message = `Successfully replaced ${result.occurrences} occurrence(s) in '${file_path}'`;
const resolved =
(await events?.onWrite?.(file_path, resolvedBackend)) ?? undefined;
const metadata = await (async () => {
if (resolved?.kind === "metadata") return resolved.data;
if (resolved?.kind === "raw-contents") {
const content = await resolvedBackend.readRaw(file_path);
return { ...content };
}
return undefined;
})();
const message = new ToolMessage({
content: `Successfully replaced ${result.occurrences} occurrence(s) in '${file_path}'`,
tool_call_id: config.toolCall?.id as string,
name: "edit_file",
metadata,
});
// If filesUpdate is present, return Command to update state
if (result.filesUpdate) {
return new Command({
update: {
files: result.filesUpdate,
messages: [
new ToolMessage({
content: message,
tool_call_id: config.toolCall?.id as string,
name: "edit_file",
}),
],
},
update: { files: result.filesUpdate, messages: [message] },
});
}
@@ -310,8 +343,12 @@ function createEditFileTool(
*/
function createGlobTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -350,8 +387,12 @@ function createGlobTool(
*/
function createGrepTool(
backend: BackendProtocol | BackendFactory,
customDescription: string | null,
options: {
customDescription: string | null;
events: FilesystemEvents | undefined;
},
) {
const { customDescription } = options;
return tool(
async (input, config) => {
const stateAndStore: StateAndStore = {
@@ -406,6 +447,17 @@ function createGrepTool(
);
}
export type FilesystemEventResponse =
| { kind: "raw-contents" }
| { kind: "metadata"; data: Record<string, unknown> };
export interface FilesystemEvents {
onWrite?: (
path: string,
backend: BackendProtocol,
) => void | FilesystemEventResponse | Promise<void | FilesystemEventResponse>;
}
/**
* Options for creating filesystem middleware.
*/
@@ -418,6 +470,8 @@ export interface FilesystemMiddlewareOptions {
customToolDescriptions?: Record<string, string> | null;
/** Optional token limit before evicting a tool result to the filesystem (default: 20000 tokens, ~80KB) */
toolTokenLimitBeforeEvict?: number | null;
/** Filesystem events callbacks */
events?: FilesystemEvents;
}
/**
@@ -431,17 +485,36 @@ export function createFilesystemMiddleware(
systemPrompt: customSystemPrompt = null,
customToolDescriptions = null,
toolTokenLimitBeforeEvict = 20000,
events = undefined,
} = options;
const systemPrompt = customSystemPrompt || FILESYSTEM_SYSTEM_PROMPT;
const tools = [
createLsTool(backend, customToolDescriptions?.ls ?? null),
createReadFileTool(backend, customToolDescriptions?.read_file ?? null),
createWriteFileTool(backend, customToolDescriptions?.write_file ?? null),
createEditFileTool(backend, customToolDescriptions?.edit_file ?? null),
createGlobTool(backend, customToolDescriptions?.glob ?? null),
createGrepTool(backend, customToolDescriptions?.grep ?? null),
createLsTool(backend, {
customDescription: customToolDescriptions?.ls ?? null,
events,
}),
createReadFileTool(backend, {
customDescription: customToolDescriptions?.read_file ?? null,
events,
}),
createWriteFileTool(backend, {
customDescription: customToolDescriptions?.write_file ?? null,
events,
}),
createEditFileTool(backend, {
customDescription: customToolDescriptions?.edit_file ?? null,
events,
}),
createGlobTool(backend, {
customDescription: customToolDescriptions?.glob ?? null,
events,
}),
createGrepTool(backend, {
customDescription: customToolDescriptions?.grep ?? null,
events,
}),
];
const FilesystemStateSchema = z3.object({
+156 -5
View File
@@ -26,7 +26,7 @@ function createTempDir(): string {
async function removeDir(dirPath: string) {
try {
await fs.rm(dirPath, { recursive: true, force: true });
} catch (err) {
} catch {
// Ignore errors during cleanup
}
}
@@ -63,6 +63,12 @@ describe("FilesystemBackend", () => {
const txt = await backend.read(f1);
expect(txt).toContain("hello fs");
// Test readRaw alongside read
const rawData = await backend.readRaw(f1);
expect(rawData.content).toEqual(["hello fs"]);
expect(rawData.created_at).toBeDefined();
expect(rawData.modified_at).toBeDefined();
const editMsg = await backend.edit(f1, "fs", "filesystem", false);
expect(editMsg).toBeDefined();
expect(editMsg.error).toBeUndefined();
@@ -70,7 +76,7 @@ describe("FilesystemBackend", () => {
const writeMsg = await backend.write(
path.join(root, "new.txt"),
"new content"
"new content",
);
expect(writeMsg).toBeDefined();
expect(writeMsg.error).toBeUndefined();
@@ -107,6 +113,12 @@ describe("FilesystemBackend", () => {
const txt = await backend.read("/a.txt");
expect(txt).toContain("hello virtual");
// Test readRaw alongside read
const rawData = await backend.readRaw("/a.txt");
expect(rawData.content).toEqual(["hello virtual"]);
expect(rawData.created_at).toBeDefined();
expect(rawData.modified_at).toBeDefined();
const editMsg = await backend.edit("/a.txt", "virtual", "virt", false);
expect(editMsg).toBeDefined();
expect(editMsg.error).toBeUndefined();
@@ -132,6 +144,11 @@ describe("FilesystemBackend", () => {
const traversalError = await backend.read("/../a.txt");
expect(traversalError).toContain("Error");
expect(traversalError).toContain("Path traversal not allowed");
// Test readRaw also rejects path traversal
await expect(backend.readRaw("/../a.txt")).rejects.toThrow(
"Path traversal not allowed",
);
});
it("should list nested directories correctly in virtual mode", async () => {
@@ -206,9 +223,11 @@ describe("FilesystemBackend", () => {
const subdirListing = await backend.lsInfo(path.join(root, "subdir"));
const subdirPaths = subdirListing.map((fi) => fi.path);
expect(subdirPaths).toContain(path.join(root, "subdir", "file2.txt"));
expect(subdirPaths).toContain(path.join(root, "subdir", "nested") + path.sep);
expect(subdirPaths).toContain(
path.join(root, "subdir", "nested") + path.sep,
);
expect(subdirPaths).not.toContain(
path.join(root, "subdir", "nested", "file3.txt")
path.join(root, "subdir", "nested", "file3.txt"),
);
});
@@ -240,7 +259,7 @@ describe("FilesystemBackend", () => {
const listing2 = await backend.lsInfo("/dir");
expect(listing1.length).toBe(listing2.length);
expect(listing1.map((fi) => fi.path)).toEqual(
listing2.map((fi) => fi.path)
listing2.map((fi) => fi.path),
);
const empty = await backend.lsInfo("/nonexistent/");
@@ -263,7 +282,139 @@ describe("FilesystemBackend", () => {
const readContent = await backend.read("/large_file.txt");
expect(readContent).toContain(largeContent.substring(0, 100));
// Test readRaw alongside read for large files
const rawData = await backend.readRaw("/large_file.txt");
expect(rawData.content).toEqual([largeContent]);
expect(rawData.content[0]).toHaveLength(10000);
const savedFile = path.join(root, "large_file.txt");
expect(fsSync.existsSync(savedFile)).toBe(true);
});
it("should test readRaw with multiline content", async () => {
const root = tmpDir;
const filePath = path.join(root, "multiline.txt");
await writeFile(filePath, "line1\nline2\nline3");
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const txt = await backend.read(filePath);
expect(txt).toContain("line1");
expect(txt).toContain("line2");
expect(txt).toContain("line3");
// Test readRaw alongside read
const rawData = await backend.readRaw(filePath);
expect(rawData.content).toEqual(["line1", "line2", "line3"]);
expect(rawData.created_at).toBeDefined();
expect(rawData.modified_at).toBeDefined();
expect(typeof rawData.created_at).toBe("string");
expect(typeof rawData.modified_at).toBe("string");
// Verify timestamps are valid ISO 8601
expect(new Date(rawData.created_at).toISOString()).toBe(rawData.created_at);
expect(new Date(rawData.modified_at).toISOString()).toBe(
rawData.modified_at,
);
});
it("should handle empty files with read and readRaw", async () => {
const root = tmpDir;
const filePath = path.join(root, "empty.txt");
await writeFile(filePath, "");
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const txt = await backend.read(filePath);
expect(txt).toContain("empty contents");
// Test readRaw handles empty files
const rawData = await backend.readRaw(filePath);
expect(rawData.content).toEqual([""]);
});
it("should handle files with trailing newlines", async () => {
const root = tmpDir;
const filePath = path.join(root, "trailing.txt");
await writeFile(filePath, "line1\nline2\n");
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const txt = await backend.read(filePath);
expect(txt).toContain("line1");
expect(txt).toContain("line2");
// Test readRaw preserves trailing newline as empty string
const rawData = await backend.readRaw(filePath);
expect(rawData.content).toEqual(["line1", "line2", ""]);
});
it("should handle unicode content", async () => {
const root = tmpDir;
const filePath = path.join(root, "unicode.txt");
await writeFile(filePath, "Hello 世界\n🚀 emoji\nΩ omega");
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const txt = await backend.read(filePath);
expect(txt).toContain("Hello 世界");
expect(txt).toContain("🚀 emoji");
expect(txt).toContain("Ω omega");
// Test readRaw handles unicode properly
const rawData = await backend.readRaw(filePath);
expect(rawData.content).toEqual(["Hello 世界", "🚀 emoji", "Ω omega"]);
});
it("should handle non-existent files consistently", async () => {
const root = tmpDir;
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const nonexistentPath = path.join(root, "nonexistent.txt");
const readResult = await backend.read(nonexistentPath);
expect(readResult).toContain("Error");
// readRaw should throw an error
await expect(backend.readRaw(nonexistentPath)).rejects.toThrow();
});
it("should handle symlinks securely", async () => {
const root = tmpDir;
const targetFile = path.join(root, "target.txt");
const symlinkFile = path.join(root, "symlink.txt");
await writeFile(targetFile, "target content");
try {
await fs.symlink(targetFile, symlinkFile);
} catch {
// Skip test if symlinks aren't supported (e.g., Windows without admin)
return;
}
const backend = new FilesystemBackend({
rootDir: root,
virtualMode: false,
});
const readResult = await backend.read(symlinkFile);
expect(readResult).toContain("Error");
// readRaw should also reject symlinks
await expect(backend.readRaw(symlinkFile)).rejects.toThrow();
});
});