diff --git a/.changeset/young-papers-thank.md b/.changeset/young-papers-thank.md new file mode 100644 index 000000000..cb65f9986 --- /dev/null +++ b/.changeset/young-papers-thank.md @@ -0,0 +1,5 @@ +--- +"deepagents": minor +--- + +Add readRaw method to filesystem backend protocol diff --git a/examples/backends/store-backend.ts b/examples/backends/store-backend.ts index 32691e9f7..f2ce9b3e7 100644 --- a/examples/backends/store-backend.ts +++ b/examples/backends/store-backend.ts @@ -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(); diff --git a/examples/research/research-agent.ts b/examples/research/research-agent.ts index 7746d385a..31b4cb25c 100644 --- a/examples/research/research-agent.ts +++ b/examples/research/research-agent.ts @@ -202,6 +202,7 @@ export const agent = createDeepAgent({ model: "claude-sonnet-4-20250514", temperature: 0, }), + tools: [internetSearch], systemPrompt: researchInstructions, subagents: [critiqueSubAgent, researchSubAgent], diff --git a/package.json b/package.json index 914747631..0aa505668 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d39bc1edd..54a2457b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/backends/composite.ts b/src/backends/composite.ts index 6157799fa..291092838 100644 --- a/src/backends/composite.ts +++ b/src/backends/composite.ts @@ -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 { + const [backend, strippedKey] = this.getBackendAndKey(filePath); + return await backend.readRaw(strippedKey); + } + /** * Structured search results or error string for invalid input. */ diff --git a/src/backends/filesystem.ts b/src/backends/filesystem.ts index 4be87f126..771367c73 100644 --- a/src/backends/filesystem.ts +++ b/src/backends/filesystem.ts @@ -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 { + 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. diff --git a/src/backends/protocol.ts b/src/backends/protocol.ts index 632880fb5..68b373529 100644 --- a/src/backends/protocol.ts +++ b/src/backends/protocol.ts @@ -128,6 +128,14 @@ export interface BackendProtocol { limit?: number, ): string | Promise; + /** + * Read file content as raw FileData. + * + * @param filePath - Absolute file path + * @returns Raw file content as FileData + */ + readRaw(filePath: string): FileData | Promise; + /** * Structured search results or error string for invalid input. * diff --git a/src/backends/state.ts b/src/backends/state.ts index 1b4848dfa..03cfebe0d 100644 --- a/src/backends/state.ts +++ b/src/backends/state.ts @@ -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. diff --git a/src/backends/store.ts b/src/backends/store.ts index 94b933766..7389308a7 100644 --- a/src/backends/store.ts +++ b/src/backends/store.ts @@ -238,22 +238,29 @@ export class StoreBackend implements BackendProtocol { offset: number = 0, limit: number = 2000, ): Promise { - 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 { + 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. diff --git a/src/middleware/fs.ts b/src/middleware/fs.ts index e9a7d7f13..1c0bf88b4 100644 --- a/src/middleware/fs.ts +++ b/src/middleware/fs.ts @@ -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 }; + +export interface FilesystemEvents { + onWrite?: ( + path: string, + backend: BackendProtocol, + ) => void | FilesystemEventResponse | Promise; +} + /** * Options for creating filesystem middleware. */ @@ -418,6 +470,8 @@ export interface FilesystemMiddlewareOptions { customToolDescriptions?: Record | 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({ diff --git a/tests/unit/backends/filesystem.test.ts b/tests/unit/backends/filesystem.test.ts index bbabed319..43ba0b670 100644 --- a/tests/unit/backends/filesystem.test.ts +++ b/tests/unit/backends/filesystem.test.ts @@ -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(); + }); });