Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion aider/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -753,9 +753,16 @@ def show_pretty(self):
return True

def get_abs_fnames_content(self):
# Remove deleted files from abs_fnames
deleted_fnames = [f for f in self.abs_fnames if not os.path.exists(f)]
for fname in deleted_fnames:
relative_fname = self.get_rel_fname(fname)
self.io.tool_warning(f"Dropping {relative_fname} from the chat (file was deleted).")
self.abs_fnames.remove(fname)

# Sort files by last modified time (earliest first, latest last)
sorted_fnames = sorted(
list(filter(lambda f: os.path.exists(f), self.abs_fnames)),
list(self.abs_fnames),
key=lambda fname: os.path.getmtime(fname),
)

Expand Down
16 changes: 5 additions & 11 deletions aider/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ def get_continuation(width, line_number, is_soft_wrap):

if coder:
await coder.commands.do_run("exit", "")
return ""
else:
raise SystemExit

Expand Down Expand Up @@ -1157,7 +1158,7 @@ async def confirm_ask(
self.confirmation_in_progress_event.clear() # Confirmation is in progress

try:
return await asyncio.create_task(self._confirm_ask(*args, **kwargs))
return await self._confirm_ask(*args, **kwargs)
except KeyboardInterrupt:
# Re-raise KeyboardInterrupt to allow it to propagate
raise
Expand Down Expand Up @@ -1236,16 +1237,9 @@ async def _confirm_ask(
while True:
try:
if self.prompt_session:
await self.recreate_input()

if coroutines.is_active(self.input_task):
self.prompt_session.message = question
self.prompt_session.app.invalidate()
else:
await asyncio.sleep(0)

res = await self.input_task
await asyncio.sleep(0)
# Call prompt_async directly instead of using input_task
# This allows KeyboardInterrupt to propagate properly
res = await self.prompt_session.prompt_async(question)
else:
res = await asyncio.get_event_loop().run_in_executor(
None, input, question
Expand Down
120 changes: 49 additions & 71 deletions tests/basic/test_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,6 @@ async def test_allowed_to_edit(self):

assert not coder.need_commit_before_edits

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_allowed_to_edit_no(self):
with GitTemporaryDirectory():
repo = git.Repo()
Expand All @@ -71,8 +68,8 @@ async def test_allowed_to_edit_no(self):

repo.git.commit("-m", "init")

# say NO
io = InputOutput(yes=False)
io.confirm_ask = AsyncMock(return_value=False)

coder = await Coder.create(self.GPT35, None, io, fnames=["added.txt"])

Expand Down Expand Up @@ -126,14 +123,12 @@ async def test_get_files_content(self):
assert "file1.txt" in all_file_names
assert "file2.txt" in all_file_names

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_check_for_filename_mentions(self):
with GitTemporaryDirectory():
repo = git.Repo()

mock_io = MagicMock()
mock_io.confirm_ask = AsyncMock(return_value=True)

fname1 = Path("file1.txt")
fname2 = Path("file2.py")
Expand All @@ -145,13 +140,11 @@ async def test_check_for_filename_mentions(self):
repo.git.add(str(fname2))
repo.git.commit("-m", "new")

# Initialize the Coder object with the mocked IO and mocked repo
coder = await Coder.create(self.GPT35, None, mock_io)
mock_args = MagicMock(tui=False)
coder = await Coder.create(self.GPT35, None, mock_io, args=mock_args)

# Call the check_for_file_mentions method
coder.check_for_file_mentions("Please check file1.txt and file2.py")
await coder.check_for_file_mentions("Please check file1.txt and file2.py")

# Check if coder.abs_fnames contains both files
expected_files = set(
[
str(Path(coder.root) / fname1),
Expand All @@ -161,13 +154,11 @@ async def test_check_for_filename_mentions(self):

assert coder.abs_fnames == expected_files

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_check_for_ambiguous_filename_mentions_of_longer_paths(self):
with GitTemporaryDirectory():
io = InputOutput(pretty=False, yes=True)
coder = await Coder.create(self.GPT35, None, io)
mock_args = MagicMock(tui=False)
coder = await Coder.create(self.GPT35, None, io, args=mock_args)

fname = Path("file1.txt")
fname.touch()
Expand All @@ -180,8 +171,7 @@ async def test_check_for_ambiguous_filename_mentions_of_longer_paths(self):
mock.return_value = set([str(fname), str(other_fname)])
coder.repo.get_tracked_files = mock

# Call the check_for_file_mentions method
coder.check_for_file_mentions(f"Please check {fname}!")
await coder.check_for_file_mentions(f"Please check {fname}!")

assert coder.abs_fnames == {str(fname.resolve())}

Expand Down Expand Up @@ -216,83 +206,54 @@ async def test_skip_duplicate_basename_mentions(self):
mentioned = coder.get_file_mentions(f"Check {fname1} and {fname3}")
assert mentioned == {str(fname3)}

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_check_for_file_mentions_read_only(self):
with GitTemporaryDirectory():
io = InputOutput(
pretty=False,
yes=True,
)
io = InputOutput(pretty=False, yes=True)
coder = await Coder.create(self.GPT35, None, io)

fname = Path("readonly_file.txt")
fname.touch()

coder.abs_read_only_fnames.add(str(fname.resolve()))

# Mock the get_tracked_files method
mock = MagicMock()
mock.return_value = set([str(fname)])
coder.repo.get_tracked_files = mock

# Call the check_for_file_mentions method
result = coder.check_for_file_mentions(f"Please check {fname}!")
result = await coder.check_for_file_mentions(f"Please check {fname}!")

# Assert that the method returns None (user not asked to add the file)
assert result is None

# Assert that abs_fnames is still empty (file not added)
assert coder.abs_fnames == set()

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_check_for_file_mentions_with_mocked_confirm(self):
with GitTemporaryDirectory():
io = InputOutput(pretty=False)
coder = await Coder.create(self.GPT35, None, io)
io.confirm_ask = AsyncMock(side_effect=[False, True, True])
mock_args = MagicMock(tui=False)
coder = await Coder.create(self.GPT35, None, io, args=mock_args)

# Mock get_file_mentions to return two file names
coder.get_file_mentions = MagicMock(return_value=set(["file1.txt", "file2.txt"]))

# Mock confirm_ask to return False for the first call and True for the second
io.confirm_ask = AsyncMock(side_effect=[False, True, True])

# First call to check_for_file_mentions
await coder.check_for_file_mentions("Please check file1.txt for the info")

# Assert that confirm_ask was called twice
assert io.confirm_ask.call_count == 2

# Assert that only file2.txt was added to abs_fnames
assert len(coder.abs_fnames) == 1
assert "file2.txt" in str(coder.abs_fnames)

# Reset the mock
io.confirm_ask.reset_mock()

# Second call to check_for_file_mentions
await coder.check_for_file_mentions("Please check file1.txt and file2.txt again")

# Assert that confirm_ask was called only once (for file1.txt)
assert io.confirm_ask.call_count == 1

# Assert that abs_fnames still contains only file2.txt
assert len(coder.abs_fnames) == 1
assert "file2.txt" in str(coder.abs_fnames)

# Assert that file1.txt is in ignore_mentions
assert "file1.txt" in coder.ignore_mentions

@pytest.mark.xfail(
reason="Bug in io.py:970 - UnboundLocalError when exceptions occur before line assigned"
)
async def test_check_for_subdir_mention(self):
with GitTemporaryDirectory():
io = InputOutput(pretty=False, yes=True)
coder = await Coder.create(self.GPT35, None, io)
mock_args = MagicMock(tui=False)
coder = await Coder.create(self.GPT35, None, io, args=mock_args)

fname = Path("other") / "file1.txt"
fname.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -302,8 +263,7 @@ async def test_check_for_subdir_mention(self):
mock.return_value = set([str(fname)])
coder.repo.get_tracked_files = mock

# Call the check_for_file_mentions method
coder.check_for_file_mentions(f"Please check `{fname}`")
await coder.check_for_file_mentions(f"Please check `{fname}`")

assert coder.abs_fnames == {str(fname.resolve())}

Expand Down Expand Up @@ -460,12 +420,6 @@ async def test_get_file_mentions_path_formats(self):
mentioned_files == expected_files
), f"Failed for content: {content}, addable_files: {addable_files}"

@pytest.mark.xfail(
reason=(
"Behavior change: deleted files are filtered out during processing but not removed from"
" abs_fnames"
)
)
async def test_run_with_file_deletion(self):
# Create a few temporary files

Expand Down Expand Up @@ -896,12 +850,20 @@ async def test_skip_gitignored_files_on_init(self):
f"Skipping {ignored_file.name} that matches gitignore spec."
)

@pytest.mark.xfail(reason="Commands.cmd_web method not implemented")
async def test_check_for_urls(self):
io = InputOutput(yes=True)
coder = await Coder.create(self.GPT35, None, io=io)
coder.commands.scraper = MagicMock()
coder.commands.scraper.scrape = MagicMock(return_value="some content")
mock_args = MagicMock()
mock_args.yes_always_commands = False
mock_args.disable_scraping = False
coder = await Coder.create(self.GPT35, None, io=io, args=mock_args)

# Mock the do_run command to return scraped content
async def mock_do_run(cmd_name, url, **kwargs):
if cmd_name == "web" and kwargs.get("return_content"):
return f"Scraped content from {url}"
return None

coder.commands.do_run = mock_do_run

# Test various URL formats
test_cases = [
Expand Down Expand Up @@ -1051,18 +1013,34 @@ async def test_no_suggest_shell_commands(self):
coder = await Coder.create(self.GPT35, "diff", io=io, suggest_shell_commands=False)
assert not coder.suggest_shell_commands

@pytest.mark.xfail(reason="Commands.cmd_web method not implemented")
async def test_detect_urls_enabled(self):
with GitTemporaryDirectory():
io = InputOutput(yes=True)
coder = await Coder.create(self.GPT35, "diff", io=io, detect_urls=True)
coder.commands.scraper = MagicMock()
coder.commands.scraper.scrape = MagicMock(return_value="some content")
mock_args = MagicMock()
mock_args.yes_always_commands = False
mock_args.disable_scraping = False
coder = await Coder.create(self.GPT35, "diff", io=io, detect_urls=True, args=mock_args)

# Track calls to do_run
do_run_calls = []

async def mock_do_run(cmd_name, url, **kwargs):
do_run_calls.append((cmd_name, url, kwargs))
if cmd_name == "web" and kwargs.get("return_content"):
return f"Scraped content from {url}"
return None

coder.commands.do_run = mock_do_run

# Test with a message containing a URL
message = "Check out https://example.com"
await coder.check_for_urls(message)
coder.commands.scraper.scrape.assert_called_once_with("https://example.com")

# Verify do_run was called with the web command and correct URL
assert len(do_run_calls) == 1
assert do_run_calls[0][0] == "web"
assert do_run_calls[0][1] == "https://example.com"
assert do_run_calls[0][2].get("return_content") is True

async def test_detect_urls_disabled(self):
with GitTemporaryDirectory():
Expand Down
5 changes: 0 additions & 5 deletions tests/basic/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,6 @@ def test_tool_message_unicode_fallback(self):
# The invalid Unicode should be replaced with '?'
assert converted_message == "Hello ?World"

# TODO: Fix underlying bug in io.py:970 (UnboundLocalError)
# This test will pass once the bug is fixed in the production code
@pytest.mark.xfail(
reason="Bug: confirm_ask doesn't propagate KeyboardInterrupt - revealed by pytest migration"
)
async def test_multiline_mode_restored_after_interrupt(self):
"""Test that multiline mode is restored after KeyboardInterrupt"""
io = InputOutput(fancy_input=True)
Expand Down
5 changes: 3 additions & 2 deletions tests/basic/test_wholefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import shutil
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock

import pytest

Expand Down Expand Up @@ -40,7 +40,8 @@ async def test_no_files(self):
coder.render_incremental_response(True)

async def test_no_files_new_file_should_ask(self):
io = InputOutput(yes=False) # <- yes=FALSE
io = InputOutput(yes=False)
io.confirm_ask = AsyncMock(return_value=False)
coder = WholeFileCoder(main_model=self.GPT35, io=io, fnames=[])
coder.partial_response_content = (
'To print "Hello, World!" in most programming languages, you can use the following'
Expand Down