diff --git a/pkg/tools/builtin/filesystem.go b/pkg/tools/builtin/filesystem.go index 1423985e2..6b05dfe9e 100644 --- a/pkg/tools/builtin/filesystem.go +++ b/pkg/tools/builtin/filesystem.go @@ -165,6 +165,42 @@ type EditFileArgs struct { Edits []Edit `json:"edits" jsonschema:"Array of edit operations"` } +// UnmarshalJSON handles LLM-generated arguments where "edits" may be +// a JSON string instead of a JSON array (double-serialized). +func (a *EditFileArgs) UnmarshalJSON(data []byte) error { + var raw struct { + Path string `json:"path"` + Edits json.RawMessage `json:"edits"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("failed to parse edit_file arguments: %w", err) + } + + a.Path = raw.Path + + // When edits is missing or null (e.g. during argument streaming in + // the TUI, or partial tool calls), accept the partial result. + if len(raw.Edits) == 0 || string(raw.Edits) == "null" { + return nil + } + + // Try parsing edits as an array first (normal case). + if err := json.Unmarshal(raw.Edits, &a.Edits); err == nil { + return nil + } + + // Try unwrapping a double-serialized JSON string. + var editsStr string + if err := json.Unmarshal(raw.Edits, &editsStr); err != nil { + return fmt.Errorf("edits field is neither an array nor a JSON string: %w", err) + } + if err := json.Unmarshal([]byte(editsStr), &a.Edits); err != nil { + return fmt.Errorf("failed to parse double-serialized edits string: %w", err) + } + + return nil +} + func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { diff --git a/pkg/tools/builtin/filesystem_test.go b/pkg/tools/builtin/filesystem_test.go index 668be54ad..84b0319de 100644 --- a/pkg/tools/builtin/filesystem_test.go +++ b/pkg/tools/builtin/filesystem_test.go @@ -260,6 +260,103 @@ func TestFilesystemTool_EditFile(t *testing.T) { assert.Contains(t, result.Output, "old text not found") } +func TestEditFileArgs_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantPath string + wantEdits []Edit + wantErr bool + wantErrMsg string + }{ + { + name: "normal array edits", + input: `{"path": "test.txt", "edits": [{"oldText": "hello", "newText": "world"}]}`, + wantPath: "test.txt", + wantEdits: []Edit{ + {OldText: "hello", NewText: "world"}, + }, + }, + { + name: "double-serialized string edits", + input: `{"path": "test.txt", "edits": "[{\"oldText\": \"hello\", \"newText\": \"world\"}]"}`, + wantPath: "test.txt", + wantEdits: []Edit{ + {OldText: "hello", NewText: "world"}, + }, + }, + { + name: "double-serialized multiple edits", + input: `{"path": "f.go", "edits": "[{\"oldText\": \"a\", \"newText\": \"b\"}, {\"oldText\": \"c\", \"newText\": \"d\"}]"}`, + wantPath: "f.go", + wantEdits: []Edit{ + {OldText: "a", NewText: "b"}, + {OldText: "c", NewText: "d"}, + }, + }, + { + name: "invalid JSON", + input: `not json at all`, + wantErr: true, + wantErrMsg: "invalid character", + }, + { + name: "edits is neither array nor string", + input: `{"path": "test.txt", "edits": 42}`, + wantErr: true, + wantErrMsg: "edits field is neither an array nor a JSON string", + }, + { + name: "double-serialized but inner JSON is invalid", + input: `{"path": "test.txt", "edits": "not valid json"}`, + wantErr: true, + wantErrMsg: "failed to parse double-serialized edits string", + }, + { + name: "missing edits field (partial/streaming args)", + input: `{"path": "/tmp/test.txt"}`, + wantPath: "/tmp/test.txt", + }, + { + name: "null edits field", + input: `{"path": "test.txt", "edits": null}`, + wantPath: "test.txt", + }, + { + name: "missing path with double-serialized edits", + input: `{"edits": "[{\"oldText\": \"a\", \"newText\": \"b\"}]"}`, + wantEdits: []Edit{ + {OldText: "a", NewText: "b"}, + }, + }, + { + name: "missing path with normal array edits", + input: `{"edits": [{"oldText": "a", "newText": "b"}]}`, + wantEdits: []Edit{ + {OldText: "a", NewText: "b"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var args EditFileArgs + err := json.Unmarshal([]byte(tc.input), &args) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErrMsg) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantPath, args.Path) + assert.Equal(t, tc.wantEdits, args.Edits) + }) + } +} + func TestFilesystemTool_SearchFilesContent(t *testing.T) { t.Parallel() tmpDir := t.TempDir()