@dexto/tools-filesystem 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/directory-approval.integration.test.cjs +36 -32
- package/dist/directory-approval.integration.test.js +36 -32
- package/dist/edit-file-tool.cjs +43 -19
- package/dist/edit-file-tool.js +43 -19
- package/dist/edit-file-tool.test.cjs +203 -0
- package/dist/edit-file-tool.test.d.cts +2 -0
- package/dist/edit-file-tool.test.d.ts +2 -0
- package/dist/edit-file-tool.test.js +180 -0
- package/dist/filesystem-service.cjs +17 -14
- package/dist/filesystem-service.d.cts +3 -3
- package/dist/filesystem-service.d.ts +3 -3
- package/dist/filesystem-service.js +17 -14
- package/dist/filesystem-service.test.cjs +233 -0
- package/dist/filesystem-service.test.d.cts +2 -0
- package/dist/filesystem-service.test.d.ts +2 -0
- package/dist/filesystem-service.test.js +210 -0
- package/dist/path-validator.cjs +29 -20
- package/dist/path-validator.d.cts +9 -2
- package/dist/path-validator.d.ts +9 -2
- package/dist/path-validator.js +29 -20
- package/dist/path-validator.test.cjs +54 -48
- package/dist/path-validator.test.js +54 -48
- package/dist/read-file-tool.cjs +2 -2
- package/dist/read-file-tool.js +2 -2
- package/dist/tool-provider.cjs +22 -7
- package/dist/tool-provider.d.cts +4 -1
- package/dist/tool-provider.d.ts +4 -1
- package/dist/tool-provider.js +22 -7
- package/dist/types.d.cts +6 -0
- package/dist/types.d.ts +6 -0
- package/dist/write-file-tool.cjs +41 -7
- package/dist/write-file-tool.js +46 -8
- package/dist/write-file-tool.test.cjs +217 -0
- package/dist/write-file-tool.test.d.cts +2 -0
- package/dist/write-file-tool.test.d.ts +2 -0
- package/dist/write-file-tool.test.js +194 -0
- package/package.json +2 -2
package/dist/write-file-tool.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { createPatch } from "diff";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DextoRuntimeError,
|
|
7
|
+
ApprovalType,
|
|
8
|
+
ToolError
|
|
9
|
+
} from "@dexto/core";
|
|
5
10
|
import { FileSystemErrorCode } from "./error-codes.js";
|
|
11
|
+
const previewContentHashCache = /* @__PURE__ */ new Map();
|
|
12
|
+
const FILE_NOT_EXISTS_MARKER = null;
|
|
13
|
+
function computeContentHash(content) {
|
|
14
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
15
|
+
}
|
|
6
16
|
const WriteFileInputSchema = z.object({
|
|
7
17
|
file_path: z.string().describe("Absolute path where the file should be written"),
|
|
8
18
|
content: z.string().describe("Content to write to the file"),
|
|
@@ -34,10 +44,10 @@ function createWriteFileTool(options) {
|
|
|
34
44
|
* Check if this write operation needs directory access approval.
|
|
35
45
|
* Returns custom approval request if the file is outside allowed paths.
|
|
36
46
|
*/
|
|
37
|
-
getApprovalOverride: (args) => {
|
|
47
|
+
getApprovalOverride: async (args) => {
|
|
38
48
|
const { file_path } = args;
|
|
39
49
|
if (!file_path) return null;
|
|
40
|
-
const isAllowed = fileSystemService.isPathWithinConfigAllowed(file_path);
|
|
50
|
+
const isAllowed = await fileSystemService.isPathWithinConfigAllowed(file_path);
|
|
41
51
|
if (isAllowed) {
|
|
42
52
|
return null;
|
|
43
53
|
}
|
|
@@ -72,15 +82,25 @@ function createWriteFileTool(options) {
|
|
|
72
82
|
},
|
|
73
83
|
/**
|
|
74
84
|
* Generate preview for approval UI - shows diff or file creation info
|
|
85
|
+
* Stores content hash for change detection in execute phase.
|
|
75
86
|
*/
|
|
76
|
-
generatePreview: async (input,
|
|
87
|
+
generatePreview: async (input, context) => {
|
|
77
88
|
const { file_path, content } = input;
|
|
78
89
|
try {
|
|
79
90
|
const originalFile = await fileSystemService.readFile(file_path);
|
|
80
91
|
const originalContent = originalFile.content;
|
|
92
|
+
if (context?.toolCallId) {
|
|
93
|
+
previewContentHashCache.set(
|
|
94
|
+
context.toolCallId,
|
|
95
|
+
computeContentHash(originalContent)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
81
98
|
return generateDiffPreview(file_path, originalContent, content);
|
|
82
99
|
} catch (error) {
|
|
83
100
|
if (error instanceof DextoRuntimeError && error.code === FileSystemErrorCode.FILE_NOT_FOUND) {
|
|
101
|
+
if (context?.toolCallId) {
|
|
102
|
+
previewContentHashCache.set(context.toolCallId, FILE_NOT_EXISTS_MARKER);
|
|
103
|
+
}
|
|
84
104
|
const lineCount = content.split("\n").length;
|
|
85
105
|
const preview = {
|
|
86
106
|
type: "file",
|
|
@@ -96,24 +116,42 @@ function createWriteFileTool(options) {
|
|
|
96
116
|
throw error;
|
|
97
117
|
}
|
|
98
118
|
},
|
|
99
|
-
execute: async (input,
|
|
119
|
+
execute: async (input, context) => {
|
|
100
120
|
const { file_path, content, create_dirs, encoding } = input;
|
|
101
121
|
let originalContent = null;
|
|
122
|
+
let fileExistsNow = false;
|
|
102
123
|
try {
|
|
103
124
|
const originalFile = await fileSystemService.readFile(file_path);
|
|
104
125
|
originalContent = originalFile.content;
|
|
126
|
+
fileExistsNow = true;
|
|
105
127
|
} catch (error) {
|
|
106
128
|
if (error instanceof DextoRuntimeError && error.code === FileSystemErrorCode.FILE_NOT_FOUND) {
|
|
107
129
|
originalContent = null;
|
|
130
|
+
fileExistsNow = false;
|
|
108
131
|
} else {
|
|
109
132
|
throw error;
|
|
110
133
|
}
|
|
111
134
|
}
|
|
135
|
+
if (context?.toolCallId && previewContentHashCache.has(context.toolCallId)) {
|
|
136
|
+
const expectedHash = previewContentHashCache.get(context.toolCallId);
|
|
137
|
+
previewContentHashCache.delete(context.toolCallId);
|
|
138
|
+
if (expectedHash === FILE_NOT_EXISTS_MARKER) {
|
|
139
|
+
if (fileExistsNow) {
|
|
140
|
+
throw ToolError.fileModifiedSincePreview("write_file", file_path);
|
|
141
|
+
}
|
|
142
|
+
} else if (expectedHash !== null) {
|
|
143
|
+
if (!fileExistsNow) {
|
|
144
|
+
throw ToolError.fileModifiedSincePreview("write_file", file_path);
|
|
145
|
+
}
|
|
146
|
+
const currentHash = computeContentHash(originalContent);
|
|
147
|
+
if (expectedHash !== currentHash) {
|
|
148
|
+
throw ToolError.fileModifiedSincePreview("write_file", file_path);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
112
152
|
const result = await fileSystemService.writeFile(file_path, content, {
|
|
113
153
|
createDirs: create_dirs,
|
|
114
|
-
encoding
|
|
115
|
-
backup: true
|
|
116
|
-
// Always create backup for internal tools
|
|
154
|
+
encoding
|
|
117
155
|
});
|
|
118
156
|
let _display;
|
|
119
157
|
if (originalContent === null) {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var import_vitest = require("vitest");
|
|
25
|
+
var path = __toESM(require("node:path"), 1);
|
|
26
|
+
var fs = __toESM(require("node:fs/promises"), 1);
|
|
27
|
+
var os = __toESM(require("node:os"), 1);
|
|
28
|
+
var import_write_file_tool = require("./write-file-tool.js");
|
|
29
|
+
var import_filesystem_service = require("./filesystem-service.js");
|
|
30
|
+
var import_core = require("@dexto/core");
|
|
31
|
+
var import_core2 = require("@dexto/core");
|
|
32
|
+
const createMockLogger = () => ({
|
|
33
|
+
debug: import_vitest.vi.fn(),
|
|
34
|
+
info: import_vitest.vi.fn(),
|
|
35
|
+
warn: import_vitest.vi.fn(),
|
|
36
|
+
error: import_vitest.vi.fn(),
|
|
37
|
+
createChild: import_vitest.vi.fn().mockReturnThis()
|
|
38
|
+
});
|
|
39
|
+
(0, import_vitest.describe)("write_file tool", () => {
|
|
40
|
+
let mockLogger;
|
|
41
|
+
let tempDir;
|
|
42
|
+
let fileSystemService;
|
|
43
|
+
(0, import_vitest.beforeEach)(async () => {
|
|
44
|
+
mockLogger = createMockLogger();
|
|
45
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-write-test-"));
|
|
46
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
47
|
+
fileSystemService = new import_filesystem_service.FileSystemService(
|
|
48
|
+
{
|
|
49
|
+
allowedPaths: [tempDir],
|
|
50
|
+
blockedPaths: [],
|
|
51
|
+
blockedExtensions: [],
|
|
52
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
53
|
+
workingDirectory: tempDir,
|
|
54
|
+
enableBackups: false,
|
|
55
|
+
backupRetentionDays: 7
|
|
56
|
+
},
|
|
57
|
+
mockLogger
|
|
58
|
+
);
|
|
59
|
+
await fileSystemService.initialize();
|
|
60
|
+
import_vitest.vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
(0, import_vitest.afterEach)(async () => {
|
|
63
|
+
try {
|
|
64
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
(0, import_vitest.describe)("File Modification Detection - Existing Files", () => {
|
|
69
|
+
(0, import_vitest.it)("should succeed when existing file is not modified between preview and execute", async () => {
|
|
70
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
71
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
72
|
+
await fs.writeFile(testFile, "original content");
|
|
73
|
+
const toolCallId = "test-call-123";
|
|
74
|
+
const input = {
|
|
75
|
+
file_path: testFile,
|
|
76
|
+
content: "new content"
|
|
77
|
+
};
|
|
78
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
79
|
+
(0, import_vitest.expect)(preview).toBeDefined();
|
|
80
|
+
(0, import_vitest.expect)(preview?.type).toBe("diff");
|
|
81
|
+
const result = await tool.execute(input, { toolCallId });
|
|
82
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
83
|
+
(0, import_vitest.expect)(result.path).toBe(testFile);
|
|
84
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
85
|
+
(0, import_vitest.expect)(content).toBe("new content");
|
|
86
|
+
});
|
|
87
|
+
(0, import_vitest.it)("should fail when existing file is modified between preview and execute", async () => {
|
|
88
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
89
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
90
|
+
await fs.writeFile(testFile, "original content");
|
|
91
|
+
const toolCallId = "test-call-456";
|
|
92
|
+
const input = {
|
|
93
|
+
file_path: testFile,
|
|
94
|
+
content: "new content"
|
|
95
|
+
};
|
|
96
|
+
await tool.generatePreview(input, { toolCallId });
|
|
97
|
+
await fs.writeFile(testFile, "user modified this");
|
|
98
|
+
try {
|
|
99
|
+
await tool.execute(input, { toolCallId });
|
|
100
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
103
|
+
(0, import_vitest.expect)(error.code).toBe(
|
|
104
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
108
|
+
(0, import_vitest.expect)(content).toBe("user modified this");
|
|
109
|
+
});
|
|
110
|
+
(0, import_vitest.it)("should fail when existing file is deleted between preview and execute", async () => {
|
|
111
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
112
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
113
|
+
await fs.writeFile(testFile, "original content");
|
|
114
|
+
const toolCallId = "test-call-deleted";
|
|
115
|
+
const input = {
|
|
116
|
+
file_path: testFile,
|
|
117
|
+
content: "new content"
|
|
118
|
+
};
|
|
119
|
+
await tool.generatePreview(input, { toolCallId });
|
|
120
|
+
await fs.unlink(testFile);
|
|
121
|
+
try {
|
|
122
|
+
await tool.execute(input, { toolCallId });
|
|
123
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
126
|
+
(0, import_vitest.expect)(error.code).toBe(
|
|
127
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
(0, import_vitest.describe)("File Modification Detection - New Files", () => {
|
|
133
|
+
(0, import_vitest.it)("should succeed when creating new file that still does not exist", async () => {
|
|
134
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
135
|
+
const testFile = path.join(tempDir, "new-file.txt");
|
|
136
|
+
const toolCallId = "test-call-new";
|
|
137
|
+
const input = {
|
|
138
|
+
file_path: testFile,
|
|
139
|
+
content: "brand new content"
|
|
140
|
+
};
|
|
141
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
142
|
+
(0, import_vitest.expect)(preview).toBeDefined();
|
|
143
|
+
(0, import_vitest.expect)(preview?.type).toBe("file");
|
|
144
|
+
(0, import_vitest.expect)(preview.operation).toBe("create");
|
|
145
|
+
const result = await tool.execute(input, { toolCallId });
|
|
146
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
147
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
148
|
+
(0, import_vitest.expect)(content).toBe("brand new content");
|
|
149
|
+
});
|
|
150
|
+
(0, import_vitest.it)("should fail when file is created by someone else between preview and execute", async () => {
|
|
151
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
152
|
+
const testFile = path.join(tempDir, "race-condition.txt");
|
|
153
|
+
const toolCallId = "test-call-race";
|
|
154
|
+
const input = {
|
|
155
|
+
file_path: testFile,
|
|
156
|
+
content: "agent content"
|
|
157
|
+
};
|
|
158
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
159
|
+
(0, import_vitest.expect)(preview?.type).toBe("file");
|
|
160
|
+
await fs.writeFile(testFile, "someone else created this");
|
|
161
|
+
try {
|
|
162
|
+
await tool.execute(input, { toolCallId });
|
|
163
|
+
import_vitest.expect.fail("Should have thrown an error");
|
|
164
|
+
} catch (error) {
|
|
165
|
+
(0, import_vitest.expect)(error).toBeInstanceOf(import_core2.DextoRuntimeError);
|
|
166
|
+
(0, import_vitest.expect)(error.code).toBe(
|
|
167
|
+
import_core.ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
171
|
+
(0, import_vitest.expect)(content).toBe("someone else created this");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
(0, import_vitest.describe)("Cache Cleanup", () => {
|
|
175
|
+
(0, import_vitest.it)("should clean up hash cache after successful execution", async () => {
|
|
176
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
177
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
178
|
+
await fs.writeFile(testFile, "original");
|
|
179
|
+
const toolCallId = "test-call-cleanup";
|
|
180
|
+
const input = {
|
|
181
|
+
file_path: testFile,
|
|
182
|
+
content: "first write"
|
|
183
|
+
};
|
|
184
|
+
await tool.generatePreview(input, { toolCallId });
|
|
185
|
+
await tool.execute(input, { toolCallId });
|
|
186
|
+
const input2 = {
|
|
187
|
+
file_path: testFile,
|
|
188
|
+
content: "second write"
|
|
189
|
+
};
|
|
190
|
+
await tool.generatePreview(input2, { toolCallId });
|
|
191
|
+
const result = await tool.execute(input2, { toolCallId });
|
|
192
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
193
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
194
|
+
(0, import_vitest.expect)(content).toBe("second write");
|
|
195
|
+
});
|
|
196
|
+
(0, import_vitest.it)("should clean up hash cache after failed execution", async () => {
|
|
197
|
+
const tool = (0, import_write_file_tool.createWriteFileTool)({ fileSystemService });
|
|
198
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
199
|
+
await fs.writeFile(testFile, "original");
|
|
200
|
+
const toolCallId = "test-call-fail";
|
|
201
|
+
const input = {
|
|
202
|
+
file_path: testFile,
|
|
203
|
+
content: "new content"
|
|
204
|
+
};
|
|
205
|
+
await tool.generatePreview(input, { toolCallId });
|
|
206
|
+
await fs.writeFile(testFile, "modified");
|
|
207
|
+
try {
|
|
208
|
+
await tool.execute(input, { toolCallId });
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
await fs.writeFile(testFile, "reset content");
|
|
212
|
+
await tool.generatePreview(input, { toolCallId });
|
|
213
|
+
const result = await tool.execute(input, { toolCallId });
|
|
214
|
+
(0, import_vitest.expect)(result.success).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { createWriteFileTool } from "./write-file-tool.js";
|
|
6
|
+
import { FileSystemService } from "./filesystem-service.js";
|
|
7
|
+
import { ToolErrorCode } from "@dexto/core";
|
|
8
|
+
import { DextoRuntimeError } from "@dexto/core";
|
|
9
|
+
const createMockLogger = () => ({
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
info: vi.fn(),
|
|
12
|
+
warn: vi.fn(),
|
|
13
|
+
error: vi.fn(),
|
|
14
|
+
createChild: vi.fn().mockReturnThis()
|
|
15
|
+
});
|
|
16
|
+
describe("write_file tool", () => {
|
|
17
|
+
let mockLogger;
|
|
18
|
+
let tempDir;
|
|
19
|
+
let fileSystemService;
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
mockLogger = createMockLogger();
|
|
22
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dexto-write-test-"));
|
|
23
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
24
|
+
fileSystemService = new FileSystemService(
|
|
25
|
+
{
|
|
26
|
+
allowedPaths: [tempDir],
|
|
27
|
+
blockedPaths: [],
|
|
28
|
+
blockedExtensions: [],
|
|
29
|
+
maxFileSize: 10 * 1024 * 1024,
|
|
30
|
+
workingDirectory: tempDir,
|
|
31
|
+
enableBackups: false,
|
|
32
|
+
backupRetentionDays: 7
|
|
33
|
+
},
|
|
34
|
+
mockLogger
|
|
35
|
+
);
|
|
36
|
+
await fileSystemService.initialize();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
describe("File Modification Detection - Existing Files", () => {
|
|
46
|
+
it("should succeed when existing file is not modified between preview and execute", async () => {
|
|
47
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
48
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
49
|
+
await fs.writeFile(testFile, "original content");
|
|
50
|
+
const toolCallId = "test-call-123";
|
|
51
|
+
const input = {
|
|
52
|
+
file_path: testFile,
|
|
53
|
+
content: "new content"
|
|
54
|
+
};
|
|
55
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
56
|
+
expect(preview).toBeDefined();
|
|
57
|
+
expect(preview?.type).toBe("diff");
|
|
58
|
+
const result = await tool.execute(input, { toolCallId });
|
|
59
|
+
expect(result.success).toBe(true);
|
|
60
|
+
expect(result.path).toBe(testFile);
|
|
61
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
62
|
+
expect(content).toBe("new content");
|
|
63
|
+
});
|
|
64
|
+
it("should fail when existing file is modified between preview and execute", async () => {
|
|
65
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
66
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
67
|
+
await fs.writeFile(testFile, "original content");
|
|
68
|
+
const toolCallId = "test-call-456";
|
|
69
|
+
const input = {
|
|
70
|
+
file_path: testFile,
|
|
71
|
+
content: "new content"
|
|
72
|
+
};
|
|
73
|
+
await tool.generatePreview(input, { toolCallId });
|
|
74
|
+
await fs.writeFile(testFile, "user modified this");
|
|
75
|
+
try {
|
|
76
|
+
await tool.execute(input, { toolCallId });
|
|
77
|
+
expect.fail("Should have thrown an error");
|
|
78
|
+
} catch (error) {
|
|
79
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
80
|
+
expect(error.code).toBe(
|
|
81
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
85
|
+
expect(content).toBe("user modified this");
|
|
86
|
+
});
|
|
87
|
+
it("should fail when existing file is deleted between preview and execute", async () => {
|
|
88
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
89
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
90
|
+
await fs.writeFile(testFile, "original content");
|
|
91
|
+
const toolCallId = "test-call-deleted";
|
|
92
|
+
const input = {
|
|
93
|
+
file_path: testFile,
|
|
94
|
+
content: "new content"
|
|
95
|
+
};
|
|
96
|
+
await tool.generatePreview(input, { toolCallId });
|
|
97
|
+
await fs.unlink(testFile);
|
|
98
|
+
try {
|
|
99
|
+
await tool.execute(input, { toolCallId });
|
|
100
|
+
expect.fail("Should have thrown an error");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
103
|
+
expect(error.code).toBe(
|
|
104
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("File Modification Detection - New Files", () => {
|
|
110
|
+
it("should succeed when creating new file that still does not exist", async () => {
|
|
111
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
112
|
+
const testFile = path.join(tempDir, "new-file.txt");
|
|
113
|
+
const toolCallId = "test-call-new";
|
|
114
|
+
const input = {
|
|
115
|
+
file_path: testFile,
|
|
116
|
+
content: "brand new content"
|
|
117
|
+
};
|
|
118
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
119
|
+
expect(preview).toBeDefined();
|
|
120
|
+
expect(preview?.type).toBe("file");
|
|
121
|
+
expect(preview.operation).toBe("create");
|
|
122
|
+
const result = await tool.execute(input, { toolCallId });
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
125
|
+
expect(content).toBe("brand new content");
|
|
126
|
+
});
|
|
127
|
+
it("should fail when file is created by someone else between preview and execute", async () => {
|
|
128
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
129
|
+
const testFile = path.join(tempDir, "race-condition.txt");
|
|
130
|
+
const toolCallId = "test-call-race";
|
|
131
|
+
const input = {
|
|
132
|
+
file_path: testFile,
|
|
133
|
+
content: "agent content"
|
|
134
|
+
};
|
|
135
|
+
const preview = await tool.generatePreview(input, { toolCallId });
|
|
136
|
+
expect(preview?.type).toBe("file");
|
|
137
|
+
await fs.writeFile(testFile, "someone else created this");
|
|
138
|
+
try {
|
|
139
|
+
await tool.execute(input, { toolCallId });
|
|
140
|
+
expect.fail("Should have thrown an error");
|
|
141
|
+
} catch (error) {
|
|
142
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
143
|
+
expect(error.code).toBe(
|
|
144
|
+
ToolErrorCode.FILE_MODIFIED_SINCE_PREVIEW
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
148
|
+
expect(content).toBe("someone else created this");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("Cache Cleanup", () => {
|
|
152
|
+
it("should clean up hash cache after successful execution", async () => {
|
|
153
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
154
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
155
|
+
await fs.writeFile(testFile, "original");
|
|
156
|
+
const toolCallId = "test-call-cleanup";
|
|
157
|
+
const input = {
|
|
158
|
+
file_path: testFile,
|
|
159
|
+
content: "first write"
|
|
160
|
+
};
|
|
161
|
+
await tool.generatePreview(input, { toolCallId });
|
|
162
|
+
await tool.execute(input, { toolCallId });
|
|
163
|
+
const input2 = {
|
|
164
|
+
file_path: testFile,
|
|
165
|
+
content: "second write"
|
|
166
|
+
};
|
|
167
|
+
await tool.generatePreview(input2, { toolCallId });
|
|
168
|
+
const result = await tool.execute(input2, { toolCallId });
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
const content = await fs.readFile(testFile, "utf-8");
|
|
171
|
+
expect(content).toBe("second write");
|
|
172
|
+
});
|
|
173
|
+
it("should clean up hash cache after failed execution", async () => {
|
|
174
|
+
const tool = createWriteFileTool({ fileSystemService });
|
|
175
|
+
const testFile = path.join(tempDir, "test.txt");
|
|
176
|
+
await fs.writeFile(testFile, "original");
|
|
177
|
+
const toolCallId = "test-call-fail";
|
|
178
|
+
const input = {
|
|
179
|
+
file_path: testFile,
|
|
180
|
+
content: "new content"
|
|
181
|
+
};
|
|
182
|
+
await tool.generatePreview(input, { toolCallId });
|
|
183
|
+
await fs.writeFile(testFile, "modified");
|
|
184
|
+
try {
|
|
185
|
+
await tool.execute(input, { toolCallId });
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
await fs.writeFile(testFile, "reset content");
|
|
189
|
+
await tool.generatePreview(input, { toolCallId });
|
|
190
|
+
const result = await tool.execute(input, { toolCallId });
|
|
191
|
+
expect(result.success).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dexto/tools-filesystem",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "FileSystem tools provider for Dexto agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"glob": "^11.1.0",
|
|
23
23
|
"safe-regex": "^2.1.1",
|
|
24
24
|
"zod": "^3.25.0",
|
|
25
|
-
"@dexto/core": "1.5.
|
|
25
|
+
"@dexto/core": "1.5.3"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/diff": "^5.2.3",
|