mirror of
https://github.com/langchain-ai/deepagentsjs.git
synced 2026-07-19 08:26:01 -04:00
feat: add readRaw method to filesystem backend protocol
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"deepagents": minor
|
||||
---
|
||||
|
||||
Add readRaw method to filesystem backend protocol
|
||||
@@ -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();
|
||||
|
||||
@@ -202,6 +202,7 @@ export const agent = createDeepAgent({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
temperature: 0,
|
||||
}),
|
||||
|
||||
tools: [internetSearch],
|
||||
systemPrompt: researchInstructions,
|
||||
subagents: [critiqueSubAgent, researchSubAgent],
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
Generated
+10
-26
@@ -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: {}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user