Skip to content

Commit 71bb11d

Browse files
authored
Merge pull request #275 from jamwil/fix-insertBlock
Fix insert-block tool call
2 parents 98d5a48 + ec1a1ab commit 71bb11d

5 files changed

Lines changed: 260 additions & 7 deletions

File tree

aider/tools/insert_block.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
format_tool_result,
1010
generate_unified_diff_snippet,
1111
handle_tool_error,
12+
is_provided,
1213
select_occurrence_index,
1314
validate_file_for_edit,
1415
)
@@ -32,7 +33,7 @@ class Tool(BaseTool):
3233
"occurrence": {"type": "integer", "default": 1},
3334
"change_id": {"type": "string"},
3435
"dry_run": {"type": "boolean", "default": False},
35-
"position": {"type": "string", "enum": ["top", "bottom"]},
36+
"position": {"type": "string", "enum": ["top", "bottom", ""]},
3637
"auto_indent": {"type": "boolean", "default": True},
3738
"use_regex": {"type": "boolean", "default": False},
3839
},
@@ -68,14 +69,14 @@ def execute(
6869
occurrence: Which occurrence of the pattern to use (1-based, or -1 for last)
6970
change_id: Optional ID for tracking changes
7071
dry_run: If True, only simulate the change
71-
position: Special position like "start_of_file" or "end_of_file"
72+
position: Special position like "top" or "bottom" (mutually exclusive with before_pattern and after_pattern)
7273
auto_indent: If True, automatically adjust indentation of inserted content
7374
use_regex: If True, treat patterns as regular expressions
7475
"""
7576
tool_name = "InsertBlock"
7677
try:
7778
# 1. Validate parameters
78-
if sum(x is not None for x in [after_pattern, before_pattern, position]) != 1:
79+
if sum(is_provided(x) for x in [after_pattern, before_pattern, position]) != 1:
7980
raise ToolError(
8081
"Must specify exactly one of: after_pattern, before_pattern, or position"
8182
)

aider/tools/show_numbered_context.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import os
22

33
from aider.tools.utils.base_tool import BaseTool
4-
from aider.tools.utils.helpers import ToolError, handle_tool_error, resolve_paths
4+
from aider.tools.utils.helpers import (
5+
ToolError,
6+
handle_tool_error,
7+
is_provided,
8+
resolve_paths,
9+
)
510

611

712
class Tool(BaseTool):
@@ -34,9 +39,17 @@ def execute(cls, coder, file_path, pattern=None, line_number=None, context_lines
3439
tool_name = "ShowNumberedContext"
3540
try:
3641
# 1. Validate arguments
37-
if not (pattern is None) ^ (line_number is None):
42+
pattern_provided = is_provided(pattern)
43+
line_number_provided = is_provided(line_number, treat_zero_as_missing=True)
44+
45+
if sum([pattern_provided, line_number_provided]) != 1:
3846
raise ToolError("Provide exactly one of 'pattern' or 'line_number'.")
3947

48+
if not pattern_provided:
49+
pattern = None
50+
if not line_number_provided:
51+
line_number = None
52+
4053
# 2. Resolve path
4154
abs_path, rel_path = resolve_paths(coder, file_path)
4255
if not os.path.exists(abs_path):

aider/tools/utils/helpers.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ class ToolError(Exception):
1010
pass
1111

1212

13+
def is_provided(value, *, treat_zero_as_missing=False):
14+
"""
15+
Normalizes parameter presence checks across tools.
16+
17+
Returns True when the value should be considered user-provided.
18+
"""
19+
if value is None:
20+
return False
21+
if isinstance(value, str) and value == "":
22+
return False
23+
if treat_zero_as_missing and isinstance(value, (int, float)) and value == 0:
24+
return False
25+
return True
26+
27+
1328
def resolve_paths(coder, file_path):
1429
"""Resolves absolute and relative paths for a given file path."""
1530
try:
@@ -105,10 +120,11 @@ def determine_line_range(
105120
Determines the end line index based on end_pattern or line_count.
106121
Raises ToolError if end_pattern is not found or line_count is invalid.
107122
"""
123+
108124
# Parameter validation: Ensure only one targeting method is used
109125
targeting_methods = [
110-
target_symbol is not None,
111-
start_pattern_line_index is not None,
126+
is_provided(target_symbol),
127+
is_provided(start_pattern_line_index),
112128
# Note: line_count and end_pattern depend on start_pattern_line_index
113129
]
114130
if sum(targeting_methods) > 1:

tests/tools/test_insert_block.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
7+
from aider.tools import insert_block
8+
9+
10+
class DummyIO:
11+
def __init__(self):
12+
self.tool_error = Mock()
13+
self.tool_warning = Mock()
14+
self.tool_output = Mock()
15+
16+
def read_text(self, path):
17+
return Path(path).read_text()
18+
19+
def write_text(self, path, content):
20+
Path(path).write_text(content)
21+
22+
23+
class DummyChangeTracker:
24+
def __init__(self):
25+
self.calls = []
26+
27+
def track_change(
28+
self, file_path, change_type, original_content, new_content, metadata, change_id=None
29+
):
30+
self.calls.append(
31+
{
32+
"file_path": file_path,
33+
"change_type": change_type,
34+
"original_content": original_content,
35+
"new_content": new_content,
36+
"metadata": metadata,
37+
"change_id": change_id,
38+
}
39+
)
40+
return f"change-{len(self.calls)}"
41+
42+
43+
class DummyCoder:
44+
def __init__(self, root):
45+
self.root = str(root)
46+
self.repo = SimpleNamespace(root=str(root))
47+
self.io = DummyIO()
48+
self.change_tracker = DummyChangeTracker()
49+
self.aider_edited_files = set()
50+
self.files_edited_by_tools = set()
51+
self.abs_read_only_fnames = set()
52+
self.abs_fnames = set()
53+
54+
def abs_root_path(self, file_path):
55+
path = Path(file_path)
56+
if path.is_absolute():
57+
return str(path)
58+
return str((Path(self.root) / path).resolve())
59+
60+
def get_rel_fname(self, abs_path):
61+
return str(Path(abs_path).resolve().relative_to(self.root))
62+
63+
64+
@pytest.fixture
65+
def coder_with_file(tmp_path):
66+
file_path = tmp_path / "example.txt"
67+
file_path.write_text("first line\nsecond line\n")
68+
coder = DummyCoder(tmp_path)
69+
coder.abs_fnames.add(str(file_path.resolve()))
70+
return coder, file_path
71+
72+
73+
def test_position_top_succeeds_with_no_patterns(coder_with_file):
74+
coder, file_path = coder_with_file
75+
76+
result = insert_block.Tool.execute(
77+
coder,
78+
file_path="example.txt",
79+
content="inserted line",
80+
position="top",
81+
)
82+
83+
assert result.startswith("Successfully executed InsertBlock.")
84+
assert file_path.read_text().splitlines()[0] == "inserted line"
85+
coder.io.tool_error.assert_not_called()
86+
87+
88+
def test_position_top_ignores_blank_patterns(coder_with_file):
89+
coder, file_path = coder_with_file
90+
91+
result = insert_block.Tool.execute(
92+
coder,
93+
file_path="example.txt",
94+
content="inserted line",
95+
position="top",
96+
after_pattern="",
97+
)
98+
99+
assert result.startswith("Successfully executed InsertBlock.")
100+
assert file_path.read_text().splitlines()[0] == "inserted line"
101+
coder.io.tool_error.assert_not_called()
102+
103+
104+
def test_mutually_exclusive_parameters_raise(coder_with_file):
105+
coder, file_path = coder_with_file
106+
107+
result = insert_block.Tool.execute(
108+
coder,
109+
file_path="example.txt",
110+
content="new line",
111+
position="top",
112+
after_pattern="first line",
113+
)
114+
115+
assert result.startswith("Error: Must specify exactly one of")
116+
assert file_path.read_text().startswith("first line")
117+
coder.io.tool_error.assert_called()
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
7+
from aider.tools import show_numbered_context
8+
9+
10+
class DummyIO:
11+
def __init__(self):
12+
self.tool_error = Mock()
13+
self.tool_warning = Mock()
14+
self.tool_output = Mock()
15+
16+
def read_text(self, path):
17+
return Path(path).read_text()
18+
19+
def write_text(self, path, content):
20+
Path(path).write_text(content)
21+
22+
23+
class DummyCoder:
24+
def __init__(self, root):
25+
self.root = str(root)
26+
self.repo = SimpleNamespace(root=str(root))
27+
self.io = DummyIO()
28+
29+
def abs_root_path(self, file_path):
30+
path = Path(file_path)
31+
if path.is_absolute():
32+
return str(path)
33+
return str((Path(self.root) / path).resolve())
34+
35+
def get_rel_fname(self, abs_path):
36+
return str(Path(abs_path).resolve().relative_to(self.root))
37+
38+
39+
@pytest.fixture
40+
def coder_with_file(tmp_path):
41+
file_path = tmp_path / "example.txt"
42+
file_path.write_text("alpha\nbeta\ngamma\n")
43+
coder = DummyCoder(tmp_path)
44+
return coder, file_path
45+
46+
47+
def test_pattern_with_zero_line_number_is_allowed(coder_with_file):
48+
coder, file_path = coder_with_file
49+
50+
result = show_numbered_context.Tool.execute(
51+
coder,
52+
file_path="example.txt",
53+
pattern="beta",
54+
line_number=0,
55+
context_lines=0,
56+
)
57+
58+
assert "beta" in result
59+
assert "line 2" in result or "2 | beta" in result
60+
coder.io.tool_error.assert_not_called()
61+
62+
63+
def test_empty_pattern_uses_line_number(coder_with_file):
64+
coder, file_path = coder_with_file
65+
66+
result = show_numbered_context.Tool.execute(
67+
coder,
68+
file_path="example.txt",
69+
pattern="",
70+
line_number=2,
71+
context_lines=0,
72+
)
73+
74+
assert "2 | beta" in result
75+
coder.io.tool_error.assert_not_called()
76+
77+
78+
def test_conflicting_pattern_and_line_number_raise(coder_with_file):
79+
coder, file_path = coder_with_file
80+
81+
result = show_numbered_context.Tool.execute(
82+
coder,
83+
file_path="example.txt",
84+
pattern="beta",
85+
line_number=2,
86+
context_lines=0,
87+
)
88+
89+
assert result.startswith("Error: Provide exactly one of")
90+
coder.io.tool_error.assert_called()
91+
92+
93+
def test_target_symbol_empty_string_treated_as_missing():
94+
from aider.tools.utils import helpers
95+
from aider.tools.utils.helpers import ToolError
96+
97+
with pytest.raises(ToolError, match="Must specify either target_symbol or start_pattern"):
98+
helpers.determine_line_range(
99+
coder=SimpleNamespace(repo_map=None), # repo_map not used in this path
100+
file_path="dummy",
101+
lines=["a", "b"],
102+
target_symbol="",
103+
start_pattern_line_index=None,
104+
end_pattern=None,
105+
line_count=1,
106+
)

0 commit comments

Comments
 (0)