diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 281771ceca6..dfff1d7d146 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,11 +64,14 @@ docker build -t cecli -f docker/Dockerfile . ## Coding Standards -It really helps the merge process if your PR: - -1. complies with project coding standards -2. includes test coverage -3. updates the relevant user-facing documentation, including the output of `/help` and `--help` as well as notes in config files and the web-site. +In order for your PR to be accepted it must: + +1. Be up to date with the main branch +2. Comply with project coding standards (including running the pre-commit formatting hooks) +3. Include test coverage +4. Update relevant user-facing documentation: + - Primary documentation will live in `aider/website/docs/config/` + - Check new cli arguments with the output of `/help` and `--help` ### Python Compatibility diff --git a/aider/__init__.py b/aider/__init__.py index ce04b1d2e50..478a2f82558 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.90.0.dev" +__version__ = "0.90.4.dev" safe_version = __version__ try: diff --git a/aider/args.py b/aider/args.py index 43240ee55bd..1453cca90a1 100644 --- a/aider/args.py +++ b/aider/args.py @@ -246,8 +246,8 @@ def get_parser(default_config_files, git_root): group = parser.add_argument_group("TUI Settings") group.add_argument( "--tui", - action="store_true", - default=False, + action=argparse.BooleanOptionalAction, + default=None, help="Launch Textual TUI interface (experimental)", ) group.add_argument( @@ -718,7 +718,7 @@ def get_parser(default_config_files, git_root): "--check-update", action=argparse.BooleanOptionalAction, help="Check for new aider versions on launch", - default=False, + default=True, ) group.add_argument( "--show-release-notes", @@ -803,10 +803,8 @@ def get_parser(default_config_files, git_root): ) group.add_argument( "--linear-output", - action="store_true", - help=( - "Run input and output sequentially instead of us simultaneous streams (default: False)" - ), + action=argparse.BooleanOptionalAction, + help="Run input and output sequentially instead of us simultaneous streams (default: True)", default=True, ) group.add_argument( diff --git a/aider/coders/__init__.py b/aider/coders/__init__.py index ebe4a47dd14..bbe3e1dd15f 100644 --- a/aider/coders/__init__.py +++ b/aider/coders/__init__.py @@ -3,6 +3,7 @@ from .ask_coder import AskCoder from .base_coder import Coder from .context_coder import ContextCoder +from .copypaste_coder import CopyPasteCoder from .editblock_coder import EditBlockCoder from .editblock_fenced_coder import EditBlockFencedCoder from .editor_diff_fenced_coder import EditorDiffFencedCoder @@ -33,4 +34,5 @@ EditorDiffFencedCoder, ContextCoder, AgentCoder, + CopyPasteCoder, ] diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index 36a968df182..e717bed6ccb 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -81,12 +81,9 @@ class AgentCoder(Coder): """Mode where the LLM autonomously manages which files are in context.""" edit_format = "agent" + gpt_prompts = AgentPrompts() def __init__(self, *args, **kwargs): - # Initialize appropriate prompt set before calling parent constructor - # This needs to happen before super().__init__ so the parent class has access to gpt_prompts - self.gpt_prompts = AgentPrompts() - # Dictionary to track recently removed files self.recently_removed = {} @@ -1203,6 +1200,8 @@ async def reply_completed(self): if self.agent_finished: self.tool_usage_history = [] + if self.files_edited_by_tools: + _ = await self.auto_commit(self.files_edited_by_tools) return True # Since we are no longer suppressing, the partial_response_content IS the final content. diff --git a/aider/coders/agent_prompts.py b/aider/coders/agent_prompts.py index 7d0bf390d48..0aa2bb771f6 100644 --- a/aider/coders/agent_prompts.py +++ b/aider/coders/agent_prompts.py @@ -17,9 +17,8 @@ class AgentPrompts(CoderPrompts): ## Core Directives - **Role**: Act as an expert software engineer. - **Act Proactively**: Autonomously use file discovery and context management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `View`, `Remove`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. -- **Be Decisive**: Do not ask the same question or search for the same term in multiple ways. Trust that your initial findings are valid. -- **Be Concise**: Keep all responses brief and direct (1-3 sentences). Avoid preamble, postamble, and unnecessary explanations. -- **Confirm Ambiguity**: Before applying complex or ambiguous edits, briefly state your plan. For simple, direct edits, proceed without confirmation. +- **Be Decisive**: Trust that your initial findings are valid. Refrain from asking the same question or searching for the same term in multiple similar ways. +- **Be Concise**: Keep all responses brief and direct (1-3 sentences). Avoid preamble, postamble, and unnecessary explanations. Do not repeat yourself. diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index f4a311878be..fc74626e096 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -172,7 +172,7 @@ async def create( if from_coder: main_model = from_coder.main_model else: - main_model = models.Model(models.DEFAULT_MODEL_NAME) + main_model = models.Model(models.DEFAULT_MODEL_NAME, io=io) if edit_format == "code": edit_format = None @@ -229,6 +229,15 @@ async def create( kwargs = use_kwargs from_coder.ok_to_warm_cache = False + if ( + getattr(main_model, "copy_paste_mode", False) + and getattr(main_model, "copy_paste_transport", "api") == "clipboard" + ): + res = coders.CopyPasteCoder(main_model, io, args=args, **kwargs) + await res.initialize_mcp_tools() + res.original_kwargs = dict(kwargs) + return res + for coder in coders.__all__: if hasattr(coder, "edit_format") and coder.edit_format == edit_format: res = coder(main_model, io, args=args, **kwargs) @@ -379,6 +388,9 @@ def __init__( self.io = io self.io.coder = weakref.ref(self) + self.manual_copy_paste = getattr(main_model, "copy_paste_transport", "api") == "clipboard" + self.copy_paste_mode = getattr(main_model, "copy_paste_mode", False) or auto_copy_context + self.shell_commands = [] self.partial_response_tool_calls = [] @@ -399,7 +411,7 @@ def __init__( self.main_model.reasoning_tag if self.main_model.reasoning_tag else REASONING_TAG ) - self.stream = stream and main_model.streaming + self.stream = stream and main_model.streaming and not self.manual_copy_paste if cache_prompts and self.main_model.cache_control: self.add_cache_headers = True @@ -581,6 +593,8 @@ def get_announcements(self): output += ", prompt cache" if main_model.info.get("supports_assistant_prefill"): output += ", infinite output" + if self.copy_paste_mode: + output += ", copy/paste mode" lines.append(output) @@ -639,7 +653,7 @@ def get_announcements(self): if self.done_messages: lines.append("Restored previous conversation history.") - if self.io.multiline_mode: + if self.io.multiline_mode and not self.args.tui: lines.append("Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text") return lines @@ -2823,8 +2837,8 @@ def add_assistant_reply_to_cur_messages(self): # but response.dict() is the Pydantic V1 method name. response_dict = dict(response) except TypeError: - print("Neither model_dump() nor dict() worked as expected.") - raise + print("Response parsing error.") + return msg = response_dict["choices"][0]["message"] diff --git a/aider/coders/copypaste_coder.py b/aider/coders/copypaste_coder.py new file mode 100644 index 00000000000..f7e4e55337b --- /dev/null +++ b/aider/coders/copypaste_coder.py @@ -0,0 +1,226 @@ +import hashlib +import json +import math +import time +import uuid + +from aider.exceptions import LiteLLMExceptions +from aider.llm import litellm + +from .base_coder import Coder + + +class CopyPasteCoder(Coder): + """Coder implementation that performs clipboard-driven interactions. + + This coder swaps the transport mechanism (clipboard vs API) but must remain compatible with the + base ``Coder`` interface. In particular, many base methods assume ``self.gpt_prompts`` exists. + + We therefore mirror the prompt pack from the coder that matches the currently selected + ``edit_format``. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Ensure CopyPasteCoder always has a prompt pack. + # We mirror prompts from the coder that matches the active edit format. + self._init_prompts_from_selected_edit_format() + + def _init_prompts_from_selected_edit_format(self): + """Initialize ``self.gpt_prompts`` based on the currently selected edit format. + + This prevents AttributeError crashes when base ``Coder`` code assumes ``self.gpt_prompts`` + exists (eg during message formatting, announcements, cancellation/cleanup paths, etc). + """ + # Determine the selected edit_format the same way Coder.create() does. + selected_edit_format = None + if getattr(self, "args", None) is not None and getattr(self.args, "edit_format", None): + selected_edit_format = self.args.edit_format + else: + selected_edit_format = getattr(self.main_model, "edit_format", None) + + # "code" is treated like None in Coder.create() + if selected_edit_format == "code": + selected_edit_format = None + + # If no edit format is selected, fall back to model default. + if selected_edit_format is None: + selected_edit_format = getattr(self.main_model, "edit_format", None) + + # Find the coder class that would have been selected for this edit_format. + try: + import aider.coders as coders + except Exception: + coders = None + + target_coder_class = None + if coders is not None: + for coder_cls in getattr(coders, "__all__", []): + if ( + hasattr(coder_cls, "edit_format") + and coder_cls.edit_format == selected_edit_format + ): + target_coder_class = coder_cls + break + + # Mirror prompt pack + edit_format where available. + if target_coder_class is not None and hasattr(target_coder_class, "gpt_prompts"): + self.gpt_prompts = target_coder_class.gpt_prompts + # Keep announcements/formatting consistent with the selected coder. + self.edit_format = getattr(target_coder_class, "edit_format", self.edit_format) + return + + # Last-resort fallback: avoid crashing if we can't determine the prompts. + # Prefer keeping any existing gpt_prompts (if one was set elsewhere). + if not hasattr(self, "gpt_prompts"): + self.gpt_prompts = None + + async def send(self, messages, model=None, functions=None, tools=None): + model = model or self.main_model + + if getattr(model, "copy_paste_transport", "api") == "api": + async for chunk in super().send( + messages, model=model, functions=functions, tools=tools + ): + yield chunk + return + + if functions: + self.io.tool_warning("copy/paste mode ignores function call requests.") + if tools: + self.io.tool_warning("copy/paste mode ignores tool call requests.") + + self.io.reset_streaming_response() + + # Base Coder methods (eg show_send_output/preprocess_response) expect these streaming + # attributes to always exist, even when we bypass the normal API streaming path. + self.partial_response_content = "" + self.partial_response_function_call = None + # preprocess_response() does len(self.partial_response_tool_calls), so it must not be None. + self.partial_response_tool_calls = [] + + try: + hash_object, completion = self.copy_paste_completion(messages, model) + self.chat_completion_call_hashes.append(hash_object.hexdigest()) + self.show_send_output(completion) + self.calculate_and_show_tokens_and_cost(messages, completion) + finally: + self.preprocess_response() + + if self.partial_response_content: + self.io.ai_output(self.partial_response_content) + + def copy_paste_completion(self, messages, model): + try: + from aider.helpers import copypaste + except ImportError: # pragma: no cover - import error path + self.io.tool_error("copy/paste mode requires the pyperclip package.") + self.io.tool_output("Install it with: pip install pyperclip") + raise + + def content_to_text(content): + """Extract text from the various content formats Aider/LLMs can produce.""" + if not content: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict): + text = part.get("text") + if isinstance(text, str): + parts.append(text) + elif isinstance(part, str): + parts.append(part) + return "".join(parts) + if isinstance(content, dict): + text = content.get("text") + if isinstance(text, str): + return text + return "" + return str(content) + + lines = [] + for message in messages: + text_content = content_to_text(message.get("content")) + if not text_content: + continue + role = message.get("role") + if role: + lines.append(f"{role.upper()}:\n{text_content}") + else: + lines.append(text_content) + + prompt_text = "\n\n".join(lines).strip() + + try: + copypaste.copy_to_clipboard(prompt_text) + except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path + self.io.tool_error(f"Unable to copy prompt to clipboard: {err}") + raise + + self.io.tool_output("Request copied to clipboard.") + self.io.tool_output("Paste it into your LLM interface, then copy the reply back.") + self.io.tool_output("Waiting for clipboard updates (Ctrl+C to cancel)...") + + try: + last_value = copypaste.read_clipboard() + except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path + self.io.tool_error(f"Unable to read clipboard: {err}") + raise + + try: + response_text = copypaste.wait_for_clipboard_change(initial=last_value) + except copypaste.ClipboardError as err: # pragma: no cover - clipboard error path + self.io.tool_error(f"Unable to read clipboard: {err}") + raise + + # Estimate tokens locally using the model's tokenizer; fallback to heuristic. + def _safe_token_count(text): + """Return token count via the model tokenizer, falling back to a heuristic.""" + if not text: + return 0 + try: + count = model.token_count(text) + if isinstance(count, int) and count >= 0: + return count + except Exception as ex: + # Try to map known LiteLLM exceptions to user-friendly messages, then fall back. + try: + ex_info = LiteLLMExceptions().get_ex_info(ex) + if ex_info and ex_info.description: + self.io.tool_warning( + f"Token count failed: {ex_info.description} Falling back to heuristic." + ) + except Exception: + # Avoid masking the original issue during error mapping. + pass + return int(math.ceil(len(text) / 4)) + + prompt_tokens = _safe_token_count(prompt_text) + completion_tokens = _safe_token_count(response_text) + total_tokens = prompt_tokens + completion_tokens + + completion = litellm.ModelResponse( + id=f"chatcmpl-{uuid.uuid4()}", + choices=[ + litellm.Choices( + index=0, + finish_reason="stop", + message=litellm.Message(role="assistant", content=response_text), + ) + ], + created=int(time.time()), + model=model.name, + usage={ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + }, + ) + + kwargs = dict(model=model.name, messages=messages, stream=False) + hash_object = hashlib.sha1(json.dumps(kwargs, sort_keys=True).encode()) # nosec B324 + return hash_object, completion diff --git a/aider/commands.py b/aider/commands.py index 843c0691574..2d7f104918c 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -100,6 +100,7 @@ async def cmd_model(self, args): model_name, editor_model=self.coder.main_model.editor_model.name, weak_model=self.coder.main_model.weak_model.name, + io=self.io, ) await models.sanity_check_models(self.io, model) @@ -172,6 +173,7 @@ async def cmd_weak_model(self, args): self.coder.main_model.name, editor_model=self.coder.main_model.editor_model.name, weak_model=model_name, + io=self.io, ) await models.sanity_check_models(self.io, model) raise SwitchCoder(main_model=model) diff --git a/aider/copypaste.py b/aider/copypaste.py deleted file mode 100644 index c8dfbe378d0..00000000000 --- a/aider/copypaste.py +++ /dev/null @@ -1,72 +0,0 @@ -import threading -import time - -import pyperclip - - -class ClipboardWatcher: - """Watches clipboard for changes and updates IO placeholder""" - - def __init__(self, io, verbose=False): - self.io = io - self.verbose = verbose - self.stop_event = None - self.watcher_thread = None - self.last_clipboard = None - self.io.clipboard_watcher = self - - def start(self): - """Start watching clipboard for changes""" - self.stop_event = threading.Event() - self.last_clipboard = pyperclip.paste() - - def watch_clipboard(): - while not self.stop_event.is_set(): - try: - current = pyperclip.paste() - if current != self.last_clipboard: - self.last_clipboard = current - self.io.interrupt_input() - self.io.placeholder = current - if len(current.splitlines()) > 1: - self.io.placeholder = "\n" + self.io.placeholder + "\n" - - time.sleep(0.5) - except Exception as e: - if self.verbose: - from aider.dump import dump - - dump(f"Clipboard watcher error: {e}") - continue - - self.watcher_thread = threading.Thread(target=watch_clipboard, daemon=True) - self.watcher_thread.start() - - def stop(self): - """Stop watching clipboard for changes""" - if self.stop_event: - self.stop_event.set() - if self.watcher_thread: - self.watcher_thread.join() - self.watcher_thread = None - self.stop_event = None - - -def main(): - """Example usage of the clipboard watcher""" - from aider.io import InputOutput - - io = InputOutput() - watcher = ClipboardWatcher(io, verbose=True) - - try: - watcher.start() - while True: - time.sleep(1) - except KeyboardInterrupt: - print("\nStopped watching clipboard") - watcher.stop() - - -if __name__ == "__main__": - main() diff --git a/aider/helpers/copypaste.py b/aider/helpers/copypaste.py new file mode 100644 index 00000000000..6f241f313ec --- /dev/null +++ b/aider/helpers/copypaste.py @@ -0,0 +1,123 @@ +import threading +import time + +import pyperclip + + +class ClipboardError(Exception): + """Raised when clipboard operations fail.""" + + +class ClipboardStopped(Exception): + """Raised when clipboard monitoring stops before a change occurs.""" + + +def copy_to_clipboard(text): + """Copy text to the system clipboard.""" + try: + pyperclip.copy(text) + except Exception as err: # pragma: no cover - system clipboard errors + raise ClipboardError(err) from err + + +def read_clipboard(): + """Read text from the system clipboard.""" + try: + return pyperclip.paste() + except Exception as err: # pragma: no cover - system clipboard errors + raise ClipboardError(err) from err + + +def wait_for_clipboard_change(initial=None, poll_interval=0.5, stop_event=None): + """Block until the clipboard value changes and return the new contents.""" + last_value = initial + if last_value is None: + last_value = read_clipboard() + + while True: + current = read_clipboard() + if current != last_value: + return current + + if stop_event: + if stop_event.wait(poll_interval): + raise ClipboardStopped() + else: + time.sleep(poll_interval) + + +class ClipboardWatcher: + """Watches clipboard for changes and updates IO placeholder.""" + + def __init__(self, io, verbose=False): + self.io = io + self.verbose = verbose + self.stop_event = None + self.watcher_thread = None + self.last_clipboard = None + self.io.clipboard_watcher = self + + def start(self): + """Start watching clipboard for changes.""" + self.stop_event = threading.Event() + self.last_clipboard = read_clipboard() + + def watch_clipboard(): + while not self.stop_event.is_set(): + try: + current = wait_for_clipboard_change( + initial=self.last_clipboard, + stop_event=self.stop_event, + ) + except ClipboardStopped: + break + except ClipboardError as err: + if self.verbose: + from aider.dump import dump + + dump(f"Clipboard watcher error: {err}") + continue + except Exception as err: # pragma: no cover - unexpected errors + if self.verbose: + from aider.dump import dump + + dump(f"Clipboard watcher unexpected error: {err}") + continue + + self.last_clipboard = current + self.io.interrupt_input() + self.io.placeholder = current + if len(current.splitlines()) > 1: + self.io.placeholder = "\n" + self.io.placeholder + "\n" + + self.watcher_thread = threading.Thread(target=watch_clipboard, daemon=True) + self.watcher_thread.start() + + def stop(self): + """Stop watching clipboard for changes.""" + if self.stop_event: + self.stop_event.set() + if self.watcher_thread: + self.watcher_thread.join() + self.watcher_thread = None + self.stop_event = None + + +def main(): + """Example usage of the clipboard watcher.""" + from aider.io import InputOutput + + io = InputOutput() + watcher = ClipboardWatcher(io, verbose=True) + + try: + watcher.start() + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopped watching clipboard") + watcher.stop() + + +if __name__ == "__main__": + main() diff --git a/aider/io.py b/aider/io.py index 80854cfcc6d..2b8a6ed55a7 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1,6 +1,8 @@ +import ast import asyncio import base64 import functools +import json import os import re import shutil @@ -1389,11 +1391,39 @@ def _tool_message(self, message="", strip=True, color=None): message = str(message).encode("ascii", errors="replace").decode("ascii") self.stream_print(message, style=style) + def format_json_in_string(self, text): + if not isinstance(text, str): + return text + + def replace_json(match): + full_match = match.group(0) + try: + # Try to parse as a python literal (e.g. b'{...}') + try: + parsed = ast.literal_eval(full_match) + if isinstance(parsed, bytes): + parsed = parsed.decode("utf-8", errors="ignore") + if isinstance(parsed, str): + data = json.loads(parsed, strict=False) + return "\n" + json.dumps(data, indent=2) + "\n" + except (ValueError, SyntaxError, json.JSONDecodeError): + pass + except Exception: + pass + return full_match + + # Match b'{...}', b"[...]", '{...}', "[...]" + # Handle escaped quotes with (?=4.13.4 PyYAML>=6.0.2 diff-match-patch>=20241021 pypandoc>=1.15 -litellm>=1.75.0 +litellm>=1.80.0 flake8>=7.3.0 importlib_resources pyperclip>=1.9.0 @@ -32,19 +32,10 @@ mcp>=1.12.3 textual>=6.0.0 truststore -# The proper dependency is networkx[default], but this brings -# in matplotlib and a bunch of other deps -# https://github.com/networkx/networkx/blob/d7132daa8588f653eacac7a5bae1ee85a183fa43/pyproject.toml#L57 -# We really only need networkx itself and scipy for the repomap. -# -# >3.5 seems to not be available for py3.10 -networkx>=3.4.2 +# Replaced networkx with rustworkx for better performance in repomap +rustworkx>=0.15.0 -# This is the one networkx dependency that we need. -# Including it here explicitly because we -# didn't specify networkx[default] above. -# -# 1.16 onwards only supports python3.11+ +# scipy is still needed for other parts of the codebase scipy>=1.15.3 # GitHub Release action failing on "KeyError: 'home-page'" diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 20e30845f29..7ed6564e5c3 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -11,7 +11,7 @@ from prompt_toolkit.input import DummyInput from prompt_toolkit.output import DummyOutput -from aider.coders import Coder +from aider.coders import Coder, CopyPasteCoder from aider.dump import dump # noqa: F401 from aider.io import InputOutput from aider.main import check_gitignore, load_dotenv_files, main, setup_git @@ -89,6 +89,45 @@ async def test_main_with_subdir_repo_fnames(self, _): self.assertTrue((subdir / "foo.txt").exists()) self.assertTrue((subdir / "bar.txt").exists()) + async def test_main_copy_paste_model_overrides(self): + overrides = json.dumps({"gpt-4o": {"fast": {"temperature": 0.42}}}) + coder = await main( + [ + "--no-git", + "--exit", + "--yes", + "--model", + "cp:gpt-4o:fast", + "--model-overrides", + overrides, + ], + input=DummyInput(), + output=DummyOutput(), + return_coder=True, + ) + + self.assertIsInstance(coder, CopyPasteCoder) + self.assertTrue(coder.main_model.copy_paste_mode) + self.assertEqual(coder.main_model.copy_paste_transport, "clipboard") + self.assertEqual(coder.main_model.override_kwargs, {"temperature": 0.42}) + + @patch("aider.main.ClipboardWatcher") + async def test_main_copy_paste_flag_sets_mode(self, mock_watcher): + mock_watcher.return_value = MagicMock() + + coder = await main( + ["--no-git", "--exit", "--yes", "--copy-paste"], + input=DummyInput(), + output=DummyOutput(), + return_coder=True, + ) + + self.assertNotIsInstance(coder, CopyPasteCoder) + self.assertTrue(coder.main_model.copy_paste_mode) + self.assertEqual(coder.main_model.copy_paste_transport, "api") + self.assertTrue(coder.copy_paste_mode) + self.assertFalse(coder.manual_copy_paste) + async def test_main_with_git_config_yml(self): make_repo() diff --git a/tests/coders/test_copypaste_coder.py b/tests/coders/test_copypaste_coder.py new file mode 100644 index 00000000000..ac7b5b90ebc --- /dev/null +++ b/tests/coders/test_copypaste_coder.py @@ -0,0 +1,169 @@ +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import pytest + +from aider.coders.copypaste_coder import CopyPasteCoder +from aider.coders.editblock_coder import EditBlockCoder + + +def test_init_prompts_uses_selected_edit_format(): + coder = CopyPasteCoder.__new__(CopyPasteCoder) + coder.args = SimpleNamespace(edit_format="diff") + coder.main_model = SimpleNamespace(edit_format=None) + coder.edit_format = None + coder.gpt_prompts = None + + coder._init_prompts_from_selected_edit_format() + + assert coder.gpt_prompts is EditBlockCoder.gpt_prompts + assert coder.edit_format == EditBlockCoder.edit_format + + +def test_init_prompts_preserves_existing_when_no_match(monkeypatch): + coder = CopyPasteCoder.__new__(CopyPasteCoder) + coder.args = SimpleNamespace(edit_format="custom-format") + coder.main_model = SimpleNamespace(edit_format=None) + coder.edit_format = "original-format" + coder.gpt_prompts = "original-prompts" + + import aider.coders as coders + + monkeypatch.setattr(coders, "__all__", [], raising=False) + + coder._init_prompts_from_selected_edit_format() + + assert coder.gpt_prompts == "original-prompts" + assert coder.edit_format == "original-format" + + +@pytest.mark.asyncio +async def test_send_uses_copy_paste_flow(monkeypatch): + coder = CopyPasteCoder.__new__(CopyPasteCoder) + + io = MagicMock() + coder.io = io + coder.stream = False + coder.partial_response_content = "" + coder.partial_response_tool_calls = [] + coder.partial_response_function_call = None + coder.chat_completion_call_hashes = [] + coder.show_send_output = MagicMock() + coder.calculate_and_show_tokens_and_cost = MagicMock() + + def fake_preprocess_response(): + coder.partial_response_content = "final-response" + + coder.preprocess_response = fake_preprocess_response + + class ModelStub: + copy_paste_mode = True + copy_paste_transport = "clipboard" + name = "cp:gpt-4o" + + @staticmethod + def token_count(text): + return len(text) + + coder.main_model = ModelStub() + + hash_obj = MagicMock() + hash_obj.hexdigest.return_value = "hash" + completion = MagicMock() + + with patch.object( + CopyPasteCoder, "copy_paste_completion", return_value=(hash_obj, completion) + ) as mock_completion: + messages = [{"role": "user", "content": "Hello"}] + chunks = [chunk async for chunk in coder.send(messages)] + + assert chunks == [] + mock_completion.assert_called_once_with(messages, coder.main_model) + coder.show_send_output.assert_called_once_with(completion) + coder.calculate_and_show_tokens_and_cost.assert_called_once_with(messages, completion) + assert coder.chat_completion_call_hashes == ["hash"] + coder.io.ai_output.assert_called_once_with("final-response") + + +def test_copy_paste_completion_interacts_with_clipboard(monkeypatch): + coder = CopyPasteCoder.__new__(CopyPasteCoder) + + io = MagicMock() + coder.io = io + + import aider.helpers.copypaste as copypaste + + copy_mock = MagicMock() + read_mock = MagicMock(return_value="initial value") + wait_mock = MagicMock(return_value="assistant reply") + + monkeypatch.setattr(copypaste, "copy_to_clipboard", copy_mock) + monkeypatch.setattr(copypaste, "read_clipboard", read_mock) + monkeypatch.setattr(copypaste, "wait_for_clipboard_change", wait_mock) + + class DummyMessage: + def __init__(self, **kwargs): + self.data = kwargs + + class DummyChoices: + def __init__(self, **kwargs): + self.data = kwargs + + class DummyModelResponse: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr("aider.coders.copypaste_coder.litellm.Message", DummyMessage) + monkeypatch.setattr("aider.coders.copypaste_coder.litellm.Choices", DummyChoices) + monkeypatch.setattr("aider.coders.copypaste_coder.litellm.ModelResponse", DummyModelResponse) + + class ModelStub: + name = "cp:gpt-4o" + copy_paste_mode = True + copy_paste_transport = "clipboard" + + @staticmethod + def token_count(text): + return len(text) + + model = ModelStub() + + messages = [ + {"role": "system", "content": "keep calm"}, + {"role": "user", "content": [{"text": "Hello"}, {"text": "!"}]}, + {"role": "assistant", "content": [{"text": "Prior"}, {"text": " reply"}]}, + ] + + hash_obj, completion = coder.copy_paste_completion(messages, model) + + expected_prompt = "SYSTEM:\nkeep calm\n\nUSER:\nHello!\n\nASSISTANT:\nPrior reply" + copy_mock.assert_called_once_with(expected_prompt) + read_mock.assert_called_once() + wait_mock.assert_called_once_with(initial="initial value") + + io.tool_output.assert_has_calls( + [ + call("Request copied to clipboard."), + call("Paste it into your LLM interface, then copy the reply back."), + call("Waiting for clipboard updates (Ctrl+C to cancel)..."), + ] + ) + + expected_hash = hashlib.sha1( + json.dumps( + {"model": model.name, "messages": messages, "stream": False}, sort_keys=True + ).encode() + ).hexdigest() + assert hash_obj.hexdigest() == expected_hash + + usage = completion.kwargs["usage"] + assert usage["prompt_tokens"] == len(expected_prompt) + assert usage["completion_tokens"] == len("assistant reply") + assert usage["total_tokens"] == len(expected_prompt) + len("assistant reply") + + choices = completion.kwargs["choices"] + assert len(choices) == 1 + choice_payload = choices[0].data + assert choice_payload["message"].data["content"] == "assistant reply" diff --git a/tests/tools/test_git_branch.py b/tests/tools/test_git_branch.py new file mode 100644 index 00000000000..912e4d5cbd9 --- /dev/null +++ b/tests/tools/test_git_branch.py @@ -0,0 +1,51 @@ +from pathlib import Path +from types import SimpleNamespace + +import git + +from aider.io import InputOutput +from aider.repo import GitRepo +from aider.tools import git_branch +from aider.utils import GitTemporaryDirectory + + +def _make_repo(): + repo = git.Repo() + repo.config_writer().set_value("commit", "gpgsign", "false").release() + return repo + + +def test_gitbranch_show_current_returns_branch_name(): + with GitTemporaryDirectory(): + repo = _make_repo() + Path("file.txt").write_text("content\n") + repo.git.add("file.txt") + repo.git.commit("-m", "init") + repo.git.checkout("-b", "feature") + + io = InputOutput() + git_repo = GitRepo(io, None, ".") + coder = SimpleNamespace(repo=git_repo, io=io) + + result = git_branch.Tool.execute(coder, show_current=True) + + assert result.strip() == "feature" + + +def test_gitbranch_show_current_handles_detached_head(): + with GitTemporaryDirectory(): + repo = _make_repo() + Path("file.txt").write_text("content\n") + repo.git.add("file.txt") + repo.git.commit("-m", "init") + + commit_sha = repo.head.commit.hexsha + repo.git.checkout(commit_sha) + + io = InputOutput() + git_repo = GitRepo(io, None, ".") + coder = SimpleNamespace(repo=git_repo, io=io) + + result = git_branch.Tool.execute(coder, show_current=True) + + assert result.strip() == "HEAD (detached)" diff --git a/tests/tools/test_git_diff.py b/tests/tools/test_git_diff.py new file mode 100644 index 00000000000..7924ceb9603 --- /dev/null +++ b/tests/tools/test_git_diff.py @@ -0,0 +1,29 @@ +from pathlib import Path +from types import SimpleNamespace + +import git + +from aider.io import InputOutput +from aider.repo import GitRepo +from aider.tools import git_diff +from aider.utils import GitTemporaryDirectory + + +def test_gitdiff_head_argument_includes_working_tree_changes(): + with GitTemporaryDirectory(): + repo = git.Repo() + fname = Path("example.txt") + fname.write_text("original\n") + repo.git.add(str(fname)) + repo.config_writer().set_value("commit", "gpgsign", "false").release() + repo.git.commit("-m", "initial") + + fname.write_text("updated\n") + + io = InputOutput() + git_repo = GitRepo(io, None, ".") + coder = SimpleNamespace(repo=git_repo, io=io) + + result = git_diff.Tool.execute(coder, branch="HEAD") + + assert "updated" in result