1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
|
import { resolve } from "node:path";
import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend";
import type { ToolContract, ToolResult } from "@dispatch/kernel";
// --- Pure types ---
interface ValidatedArgs {
readonly path: string;
readonly oldString: string;
readonly newString: string;
readonly replaceAll: boolean;
}
export type ReplacementError =
| { readonly kind: "identical" }
| { readonly kind: "notFound" }
| { readonly kind: "notUnique"; readonly count: number };
export interface ReplacementSuccess {
readonly content: string;
readonly count: number;
}
// --- Pure functions ---
/** Pure: validate and coerce args from the model. */
export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
if (args === null || args === undefined || typeof args !== "object") {
return { error: "Error: Arguments must be an object." };
}
const obj = args as Record<string, unknown>;
const rawPath = obj.path;
if (typeof rawPath !== "string" || rawPath.length === 0) {
return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' };
}
const rawOld = obj.oldString;
if (typeof rawOld !== "string" || rawOld.length === 0) {
return {
error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).',
};
}
const rawNew = obj.newString;
if (typeof rawNew !== "string") {
return {
error: 'Error: Missing or invalid "newString" parameter (must be a string).',
};
}
const rawReplaceAll = obj.replaceAll;
const replaceAll = rawReplaceAll === true;
return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll };
}
/** Pure: compute the replacement result given file content + params. */
export function computeReplacement(
content: string,
oldString: string,
newString: string,
replaceAll: boolean,
): ReplacementSuccess | ReplacementError {
if (oldString === newString) {
return { kind: "identical" };
}
if (oldString === "") {
return { kind: "notFound" };
}
if (!content.includes(oldString)) {
return { kind: "notFound" };
}
if (replaceAll) {
const parts = content.split(oldString);
const count = parts.length - 1;
return { content: parts.join(newString), count };
}
// Single replacement — check uniqueness.
const firstIndex = content.indexOf(oldString);
const secondIndex = content.indexOf(oldString, firstIndex + oldString.length);
if (secondIndex !== -1) {
// Count total occurrences.
let count = 0;
let idx = 0;
while (true) {
idx = content.indexOf(oldString, idx);
if (idx === -1) break;
count++;
idx += oldString.length;
}
return { kind: "notUnique", count };
}
return {
content:
content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length),
count: 1,
};
}
// --- Diagnostics hook ---
/**
* Optional post-edit diagnostics hook. Returns formatted diagnostics string
* (empty if none) + timing metadata. Injected by the extension from the LSP
* service; absent when no LSP is available (graceful degradation).
*/
export type DiagnosticsHook = (opts: {
readonly filePath: string;
readonly text: string;
readonly cwd: string;
}) => Promise<{
readonly formatted: string;
readonly slow: boolean;
readonly timedOut: boolean;
}>;
// --- Shell / edge ---
/**
* Factory: create an edit_file ToolContract.
*
* `resolveBackend` is the injected seam: each `execute` resolves an
* `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set
* id → a remote SSH backend in a later wave). The tool programs against the
* `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic.
*
* `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is
* injected so the tool is testable; `execute` prefers `ctx.cwd` when present.
*
* `diagnostics` is the post-edit LSP hook (errors+warnings from LSP servers
* are appended to successful edit results, only when errors exist). It is
* invoked LAZILY at edit time — the extension defers the LSP service lookup so
* it resolves after LSP activates. When `ctx.computerId` is set (REMOTE) the
* diagnostics call is skipped: LSP servers are local processes that can't see
* remote files over SFTP, so the no-LSP degradation path is used instead.
*/
export function createEditFileTool(deps: {
readonly resolveBackend: ExecBackendResolver;
readonly workdir?: string;
readonly diagnostics: DiagnosticsHook;
}): ToolContract {
const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined;
return {
name: "edit_file",
description:
"Perform an exact string replacement in an existing file. " +
"Provide oldString (the text to find) and newString (the replacement). " +
"By default replaces a single occurrence; set replaceAll to replace every match.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the file, relative to the working directory.",
},
oldString: {
type: "string",
description: "The exact string to find and replace.",
},
newString: {
type: "string",
description: "The string to replace oldString with.",
},
replaceAll: {
type: "boolean",
description: "Replace all occurrences (default: false).",
default: false,
},
},
required: ["path", "oldString", "newString"],
},
concurrencySafe: false,
async execute(args: unknown, ctx): Promise<ToolResult> {
const validated = validateArgs(args);
if ("error" in validated) {
return { content: validated.error, isError: true };
}
const { path: relPath, oldString, newString, replaceAll } = validated;
const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
if (effectiveBase === undefined) {
return {
content:
"Error: No working directory (neither ctx.cwd nor a baked workdir was provided).",
isError: true,
};
}
const resolvedPath = resolve(effectiveBase, relPath);
const backend: ExecBackend = deps.resolveBackend(ctx.computerId);
// Read the file.
let content: string;
try {
content = await backend.readFile(resolvedPath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return { content: `Error: File "${relPath}" not found.`, isError: true };
}
return {
content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
// Pure replacement decision.
const result = computeReplacement(content, oldString, newString, replaceAll);
if ("kind" in result) {
switch (result.kind) {
case "identical":
return {
content: "Error: newString must differ from oldString.",
isError: true,
};
case "notFound":
return {
content: `Error: oldString not found in content of "${relPath}".`,
isError: true,
};
case "notUnique":
return {
content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`,
isError: true,
};
}
}
// Write the modified content back.
try {
await backend.writeFile(resolvedPath, result.content);
} catch (err: unknown) {
return {
content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
const plural = result.count === 1 ? "" : "s";
let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`;
// After a successful edit, query LSP diagnostics (if available).
// Only append if there are actual errors/warnings (no noise on clean edits).
const diagnostics = deps.diagnostics;
if (diagnostics) {
let diag: {
readonly formatted: string;
readonly slow: boolean;
readonly timedOut: boolean;
};
if (ctx.computerId !== undefined) {
// REMOTE: LSP servers are local processes that can't see remote
// files over SFTP — skip the diagnostics call (the no-LSP
// degradation path). Forward-compatible: computerId is always
// undefined this wave, so behavior is byte-identical to today.
diag = { formatted: "", slow: false, timedOut: false };
} else {
try {
const cwd = ctx.cwd ?? process.cwd();
diag = await diagnostics({
filePath: resolvedPath,
text: result.content,
cwd,
});
} catch {
// LSP diagnostics failure is non-fatal — the edit already succeeded.
diag = { formatted: "", slow: false, timedOut: false };
}
}
const suffix: string[] = [];
if (diag.slow) {
suffix.push(
"⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.",
);
}
if (diag.formatted) {
suffix.push(diag.formatted);
}
if (suffix.length > 0) {
baseContent += `\n\n${suffix.join("\n\n")}`;
}
}
return { content: baseContent };
},
};
}
|