Skip to content

Commit 55148f7

Browse files
authored
Merge pull request #2144 from trungutt/fix-edit-file-double-serialized
fix: handle double-serialized edits argument in edit_file tool
2 parents d309f27 + 19c6b5b commit 55148f7

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

pkg/tools/builtin/filesystem.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,42 @@ type EditFileArgs struct {
165165
Edits []Edit `json:"edits" jsonschema:"Array of edit operations"`
166166
}
167167

168+
// UnmarshalJSON handles LLM-generated arguments where "edits" may be
169+
// a JSON string instead of a JSON array (double-serialized).
170+
func (a *EditFileArgs) UnmarshalJSON(data []byte) error {
171+
var raw struct {
172+
Path string `json:"path"`
173+
Edits json.RawMessage `json:"edits"`
174+
}
175+
if err := json.Unmarshal(data, &raw); err != nil {
176+
return fmt.Errorf("failed to parse edit_file arguments: %w", err)
177+
}
178+
179+
a.Path = raw.Path
180+
181+
// When edits is missing or null (e.g. during argument streaming in
182+
// the TUI, or partial tool calls), accept the partial result.
183+
if len(raw.Edits) == 0 || string(raw.Edits) == "null" {
184+
return nil
185+
}
186+
187+
// Try parsing edits as an array first (normal case).
188+
if err := json.Unmarshal(raw.Edits, &a.Edits); err == nil {
189+
return nil
190+
}
191+
192+
// Try unwrapping a double-serialized JSON string.
193+
var editsStr string
194+
if err := json.Unmarshal(raw.Edits, &editsStr); err != nil {
195+
return fmt.Errorf("edits field is neither an array nor a JSON string: %w", err)
196+
}
197+
if err := json.Unmarshal([]byte(editsStr), &a.Edits); err != nil {
198+
return fmt.Errorf("failed to parse double-serialized edits string: %w", err)
199+
}
200+
201+
return nil
202+
}
203+
168204
func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) {
169205
return []tools.Tool{
170206
{

pkg/tools/builtin/filesystem_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,103 @@ func TestFilesystemTool_EditFile(t *testing.T) {
260260
assert.Contains(t, result.Output, "old text not found")
261261
}
262262

263+
func TestEditFileArgs_UnmarshalJSON(t *testing.T) {
264+
t.Parallel()
265+
266+
tests := []struct {
267+
name string
268+
input string
269+
wantPath string
270+
wantEdits []Edit
271+
wantErr bool
272+
wantErrMsg string
273+
}{
274+
{
275+
name: "normal array edits",
276+
input: `{"path": "test.txt", "edits": [{"oldText": "hello", "newText": "world"}]}`,
277+
wantPath: "test.txt",
278+
wantEdits: []Edit{
279+
{OldText: "hello", NewText: "world"},
280+
},
281+
},
282+
{
283+
name: "double-serialized string edits",
284+
input: `{"path": "test.txt", "edits": "[{\"oldText\": \"hello\", \"newText\": \"world\"}]"}`,
285+
wantPath: "test.txt",
286+
wantEdits: []Edit{
287+
{OldText: "hello", NewText: "world"},
288+
},
289+
},
290+
{
291+
name: "double-serialized multiple edits",
292+
input: `{"path": "f.go", "edits": "[{\"oldText\": \"a\", \"newText\": \"b\"}, {\"oldText\": \"c\", \"newText\": \"d\"}]"}`,
293+
wantPath: "f.go",
294+
wantEdits: []Edit{
295+
{OldText: "a", NewText: "b"},
296+
{OldText: "c", NewText: "d"},
297+
},
298+
},
299+
{
300+
name: "invalid JSON",
301+
input: `not json at all`,
302+
wantErr: true,
303+
wantErrMsg: "invalid character",
304+
},
305+
{
306+
name: "edits is neither array nor string",
307+
input: `{"path": "test.txt", "edits": 42}`,
308+
wantErr: true,
309+
wantErrMsg: "edits field is neither an array nor a JSON string",
310+
},
311+
{
312+
name: "double-serialized but inner JSON is invalid",
313+
input: `{"path": "test.txt", "edits": "not valid json"}`,
314+
wantErr: true,
315+
wantErrMsg: "failed to parse double-serialized edits string",
316+
},
317+
{
318+
name: "missing edits field (partial/streaming args)",
319+
input: `{"path": "/tmp/test.txt"}`,
320+
wantPath: "/tmp/test.txt",
321+
},
322+
{
323+
name: "null edits field",
324+
input: `{"path": "test.txt", "edits": null}`,
325+
wantPath: "test.txt",
326+
},
327+
{
328+
name: "missing path with double-serialized edits",
329+
input: `{"edits": "[{\"oldText\": \"a\", \"newText\": \"b\"}]"}`,
330+
wantEdits: []Edit{
331+
{OldText: "a", NewText: "b"},
332+
},
333+
},
334+
{
335+
name: "missing path with normal array edits",
336+
input: `{"edits": [{"oldText": "a", "newText": "b"}]}`,
337+
wantEdits: []Edit{
338+
{OldText: "a", NewText: "b"},
339+
},
340+
},
341+
}
342+
343+
for _, tc := range tests {
344+
t.Run(tc.name, func(t *testing.T) {
345+
t.Parallel()
346+
var args EditFileArgs
347+
err := json.Unmarshal([]byte(tc.input), &args)
348+
if tc.wantErr {
349+
require.Error(t, err)
350+
assert.Contains(t, err.Error(), tc.wantErrMsg)
351+
return
352+
}
353+
require.NoError(t, err)
354+
assert.Equal(t, tc.wantPath, args.Path)
355+
assert.Equal(t, tc.wantEdits, args.Edits)
356+
})
357+
}
358+
}
359+
263360
func TestFilesystemTool_SearchFilesContent(t *testing.T) {
264361
t.Parallel()
265362
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)