mirror of
https://github.com/Aider-AI/aider.git
synced 2025-06-12 15:45:00 +00:00
Merge c1a5e8d0d5
into 3caab85931
This commit is contained in:
commit
87ee7cceee
13 changed files with 930 additions and 13 deletions
|
@ -2,7 +2,7 @@ import os
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import git
|
||||
|
||||
|
@ -575,6 +575,7 @@ Once I have these, I can show you precisely how to do the thing.
|
|||
fname = Path("file.txt")
|
||||
|
||||
io = InputOutput(yes=True)
|
||||
io.tool_warning = MagicMock()
|
||||
coder = Coder.create(self.GPT35, "diff", io=io, fnames=[str(fname)])
|
||||
|
||||
self.assertTrue(fname.exists())
|
||||
|
@ -1433,6 +1434,324 @@ This command will print 'Hello, World!' to the console."""
|
|||
# (because user rejected the changes)
|
||||
mock_editor.run.assert_not_called()
|
||||
|
||||
@patch("aider.coders.base_coder.experimental_mcp_client")
|
||||
def test_mcp_server_connection(self, mock_mcp_client):
|
||||
"""Test that the coder connects to MCP servers for tools."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
|
||||
# Create mock MCP server
|
||||
mock_server = MagicMock()
|
||||
mock_server.name = "test_server"
|
||||
mock_server.connect = MagicMock()
|
||||
mock_server.disconnect = MagicMock()
|
||||
|
||||
# Setup mock for initialize_mcp_tools
|
||||
mock_tools = [("test_server", [{"function": {"name": "test_tool"}}])]
|
||||
|
||||
# Create coder with mock MCP server
|
||||
with patch.object(Coder, "initialize_mcp_tools", return_value=mock_tools):
|
||||
coder = Coder.create(self.GPT35, "diff", io=io, mcp_servers=[mock_server])
|
||||
|
||||
# Manually set mcp_tools since we're bypassing initialize_mcp_tools
|
||||
coder.mcp_tools = mock_tools
|
||||
|
||||
# Verify that mcp_tools contains the expected data
|
||||
self.assertIsNotNone(coder.mcp_tools)
|
||||
self.assertEqual(len(coder.mcp_tools), 1)
|
||||
self.assertEqual(coder.mcp_tools[0][0], "test_server")
|
||||
|
||||
@patch("aider.coders.base_coder.experimental_mcp_client")
|
||||
def test_coder_creation_with_partial_failed_mcp_server(self, mock_mcp_client):
|
||||
"""Test that a coder can still be created even if an MCP server fails to initialize."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
io.tool_warning = MagicMock()
|
||||
|
||||
# Create mock MCP servers - one working, one failing
|
||||
working_server = AsyncMock()
|
||||
working_server.name = "working_server"
|
||||
working_server.connect = AsyncMock()
|
||||
working_server.disconnect = AsyncMock()
|
||||
|
||||
failing_server = AsyncMock()
|
||||
failing_server.name = "failing_server"
|
||||
failing_server.connect = AsyncMock()
|
||||
failing_server.disconnect = AsyncMock()
|
||||
|
||||
# Mock load_mcp_tools to succeed for working_server and fail for failing_server
|
||||
async def mock_load_mcp_tools(session, format):
|
||||
if session == await working_server.connect():
|
||||
return [{"function": {"name": "working_tool"}}]
|
||||
else:
|
||||
raise Exception("Failed to load tools")
|
||||
|
||||
mock_mcp_client.load_mcp_tools = AsyncMock(side_effect=mock_load_mcp_tools)
|
||||
|
||||
# Create coder with both servers
|
||||
coder = Coder.create(
|
||||
self.GPT35,
|
||||
"diff",
|
||||
io=io,
|
||||
mcp_servers=[working_server, failing_server],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Verify that coder was created successfully
|
||||
self.assertIsInstance(coder, Coder)
|
||||
|
||||
# Verify that only the working server's tools were added
|
||||
self.assertIsNotNone(coder.mcp_tools)
|
||||
self.assertEqual(len(coder.mcp_tools), 1)
|
||||
self.assertEqual(coder.mcp_tools[0][0], "working_server")
|
||||
|
||||
# Verify that the tool list contains only working tools
|
||||
tool_list = coder.get_tool_list()
|
||||
self.assertEqual(len(tool_list), 1)
|
||||
self.assertEqual(tool_list[0]["function"]["name"], "working_tool")
|
||||
|
||||
# Verify that the warning was logged for the failing server
|
||||
io.tool_warning.assert_called_with(
|
||||
"Error initializing MCP server failing_server:\nFailed to load tools"
|
||||
)
|
||||
|
||||
@patch("aider.coders.base_coder.experimental_mcp_client")
|
||||
def test_coder_creation_with_all_failed_mcp_server(self, mock_mcp_client):
|
||||
"""Test that a coder can still be created even if an MCP server fails to initialize."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
io.tool_warning = MagicMock()
|
||||
|
||||
failing_server = AsyncMock()
|
||||
failing_server.name = "failing_server"
|
||||
failing_server.connect = AsyncMock()
|
||||
failing_server.disconnect = AsyncMock()
|
||||
|
||||
# Mock load_mcp_tools to succeed for working_server and fail for failing_server
|
||||
async def mock_load_mcp_tools(session, format):
|
||||
raise Exception("Failed to load tools")
|
||||
|
||||
mock_mcp_client.load_mcp_tools = AsyncMock(side_effect=mock_load_mcp_tools)
|
||||
|
||||
# Create coder with both servers
|
||||
coder = Coder.create(
|
||||
self.GPT35,
|
||||
"diff",
|
||||
io=io,
|
||||
mcp_servers=[failing_server],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
# Verify that coder was created successfully
|
||||
self.assertIsInstance(coder, Coder)
|
||||
|
||||
# Verify that only the working server's tools were added
|
||||
self.assertIsNotNone(coder.mcp_tools)
|
||||
self.assertEqual(len(coder.mcp_tools), 0)
|
||||
|
||||
# Verify that the tool list contains only working tools
|
||||
tool_list = coder.get_tool_list()
|
||||
self.assertEqual(len(tool_list), 0)
|
||||
|
||||
# Verify that the warning was logged for the failing server
|
||||
io.tool_warning.assert_called_with(
|
||||
"Error initializing MCP server failing_server:\nFailed to load tools"
|
||||
)
|
||||
|
||||
def test_process_tool_calls_none_response(self):
|
||||
"""Test that process_tool_calls handles None response correctly."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
|
||||
# Test with None response
|
||||
result = coder.process_tool_calls(None)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_process_tool_calls_no_tool_calls(self):
|
||||
"""Test that process_tool_calls handles response with no tool calls."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
|
||||
# Create a response with no tool calls
|
||||
response = MagicMock()
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.tool_calls = []
|
||||
|
||||
result = coder.process_tool_calls(response)
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch("aider.coders.base_coder.experimental_mcp_client")
|
||||
@patch("asyncio.run")
|
||||
def test_process_tool_calls_with_tools(self, mock_asyncio_run, mock_mcp_client):
|
||||
"""Test that process_tool_calls processes tool calls correctly."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
io.confirm_ask = MagicMock(return_value=True)
|
||||
|
||||
# Create mock MCP server
|
||||
mock_server = MagicMock()
|
||||
mock_server.name = "test_server"
|
||||
|
||||
# Create a tool call
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "test_id"
|
||||
tool_call.type = "function"
|
||||
tool_call.function = MagicMock()
|
||||
tool_call.function.name = "test_tool"
|
||||
tool_call.function.arguments = '{"param": "value"}'
|
||||
|
||||
# Create a response with tool calls
|
||||
response = MagicMock()
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.tool_calls = [tool_call]
|
||||
response.choices[0].message.to_dict = MagicMock(
|
||||
return_value={"role": "assistant", "tool_calls": [{"id": "test_id"}]}
|
||||
)
|
||||
|
||||
# Create coder with mock MCP tools and servers
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])]
|
||||
coder.mcp_servers = [mock_server]
|
||||
|
||||
# Mock asyncio.run to return tool responses
|
||||
tool_responses = [
|
||||
[{"role": "tool", "tool_call_id": "test_id", "content": "Tool execution result"}]
|
||||
]
|
||||
mock_asyncio_run.return_value = tool_responses
|
||||
|
||||
# Test process_tool_calls
|
||||
result = coder.process_tool_calls(response)
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify that asyncio.run was called
|
||||
mock_asyncio_run.assert_called_once()
|
||||
|
||||
# Verify that the messages were added
|
||||
self.assertEqual(len(coder.cur_messages), 2)
|
||||
self.assertEqual(coder.cur_messages[0]["role"], "assistant")
|
||||
self.assertEqual(coder.cur_messages[1]["role"], "tool")
|
||||
self.assertEqual(coder.cur_messages[1]["tool_call_id"], "test_id")
|
||||
self.assertEqual(coder.cur_messages[1]["content"], "Tool execution result")
|
||||
|
||||
def test_process_tool_calls_max_calls_exceeded(self):
|
||||
"""Test that process_tool_calls handles max tool calls exceeded."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
io.tool_warning = MagicMock()
|
||||
|
||||
# Create a tool call
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "test_id"
|
||||
tool_call.type = "function"
|
||||
tool_call.function = MagicMock()
|
||||
tool_call.function.name = "test_tool"
|
||||
|
||||
# Create a response with tool calls
|
||||
response = MagicMock()
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.tool_calls = [tool_call]
|
||||
|
||||
# Create mock MCP server
|
||||
mock_server = MagicMock()
|
||||
mock_server.name = "test_server"
|
||||
|
||||
# Create coder with max tool calls exceeded
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
coder.num_tool_calls = coder.max_tool_calls
|
||||
coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])]
|
||||
coder.mcp_servers = [mock_server]
|
||||
|
||||
# Test process_tool_calls
|
||||
result = coder.process_tool_calls(response)
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify that warning was shown
|
||||
io.tool_warning.assert_called_once_with(
|
||||
f"Only {coder.max_tool_calls} tool calls allowed, stopping."
|
||||
)
|
||||
|
||||
def test_process_tool_calls_user_rejects(self):
|
||||
"""Test that process_tool_calls handles user rejection."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
io.confirm_ask = MagicMock(return_value=False)
|
||||
|
||||
# Create a tool call
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "test_id"
|
||||
tool_call.type = "function"
|
||||
tool_call.function = MagicMock()
|
||||
tool_call.function.name = "test_tool"
|
||||
|
||||
# Create a response with tool calls
|
||||
response = MagicMock()
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.tool_calls = [tool_call]
|
||||
|
||||
# Create mock MCP server
|
||||
mock_server = MagicMock()
|
||||
mock_server.name = "test_server"
|
||||
|
||||
# Create coder with mock MCP tools
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
coder.mcp_tools = [("test_server", [{"function": {"name": "test_tool"}}])]
|
||||
coder.mcp_servers = [mock_server]
|
||||
|
||||
# Test process_tool_calls
|
||||
result = coder.process_tool_calls(response)
|
||||
self.assertFalse(result)
|
||||
|
||||
# Verify that confirm_ask was called
|
||||
io.confirm_ask.assert_called_once_with("Run tools?")
|
||||
|
||||
# Verify that no messages were added
|
||||
self.assertEqual(len(coder.cur_messages), 0)
|
||||
|
||||
@patch("asyncio.run")
|
||||
def test_execute_tool_calls(self, mock_asyncio_run):
|
||||
"""Test that _execute_tool_calls executes tool calls correctly."""
|
||||
with GitTemporaryDirectory():
|
||||
io = InputOutput(yes=True)
|
||||
coder = Coder.create(self.GPT35, "diff", io=io)
|
||||
|
||||
# Create mock server and tool call
|
||||
mock_server = MagicMock()
|
||||
mock_server.name = "test_server"
|
||||
|
||||
tool_call = MagicMock()
|
||||
tool_call.id = "test_id"
|
||||
tool_call.type = "function"
|
||||
tool_call.function = MagicMock()
|
||||
tool_call.function.name = "test_tool"
|
||||
tool_call.function.arguments = '{"param": "value"}'
|
||||
|
||||
# Create server_tool_calls
|
||||
server_tool_calls = {mock_server: [tool_call]}
|
||||
|
||||
# Mock asyncio.run to return tool responses
|
||||
tool_responses = [
|
||||
[{"role": "tool", "tool_call_id": "test_id", "content": "Tool execution result"}]
|
||||
]
|
||||
mock_asyncio_run.return_value = tool_responses
|
||||
|
||||
# Test _execute_tool_calls directly
|
||||
result = coder._execute_tool_calls(server_tool_calls)
|
||||
|
||||
# Verify that asyncio.run was called
|
||||
mock_asyncio_run.assert_called_once()
|
||||
|
||||
# Verify that the correct tool responses were returned
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["role"], "tool")
|
||||
self.assertEqual(result[0]["tool_call_id"], "test_id")
|
||||
self.assertEqual(result[0]["content"], "Tool execution result")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
@ -1363,3 +1363,57 @@ class TestMain(TestCase):
|
|||
)
|
||||
for call in mock_io_instance.tool_warning.call_args_list:
|
||||
self.assertNotIn("Cost estimates may be inaccurate", call[0][0])
|
||||
|
||||
@patch("aider.coders.Coder.create")
|
||||
def test_mcp_servers_parsing(self, mock_coder_create):
|
||||
# Setup mock coder
|
||||
mock_coder_instance = MagicMock()
|
||||
mock_coder_create.return_value = mock_coder_instance
|
||||
|
||||
# Test with --mcp-servers option
|
||||
with GitTemporaryDirectory():
|
||||
main(
|
||||
[
|
||||
"--mcp-servers",
|
||||
'{"mcpServers":{"git":{"command":"uvx","args":["mcp-server-git"]}}}',
|
||||
"--exit",
|
||||
"--yes",
|
||||
],
|
||||
input=DummyInput(),
|
||||
output=DummyOutput(),
|
||||
)
|
||||
|
||||
# Verify that Coder.create was called with mcp_servers parameter
|
||||
mock_coder_create.assert_called_once()
|
||||
_, kwargs = mock_coder_create.call_args
|
||||
self.assertIn("mcp_servers", kwargs)
|
||||
self.assertIsNotNone(kwargs["mcp_servers"])
|
||||
# At least one server should be in the list
|
||||
self.assertTrue(len(kwargs["mcp_servers"]) > 0)
|
||||
# First server should have a name attribute
|
||||
self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name"))
|
||||
|
||||
# Test with --mcp-servers-file option
|
||||
mock_coder_create.reset_mock()
|
||||
|
||||
with GitTemporaryDirectory():
|
||||
# Create a temporary MCP servers file
|
||||
mcp_file = Path("mcp_servers.json")
|
||||
mcp_content = {"mcpServers": {"git": {"command": "uvx", "args": ["mcp-server-git"]}}}
|
||||
mcp_file.write_text(json.dumps(mcp_content))
|
||||
|
||||
main(
|
||||
["--mcp-servers-file", str(mcp_file), "--exit", "--yes"],
|
||||
input=DummyInput(),
|
||||
output=DummyOutput(),
|
||||
)
|
||||
|
||||
# Verify that Coder.create was called with mcp_servers parameter
|
||||
mock_coder_create.assert_called_once()
|
||||
_, kwargs = mock_coder_create.call_args
|
||||
self.assertIn("mcp_servers", kwargs)
|
||||
self.assertIsNotNone(kwargs["mcp_servers"])
|
||||
# At least one server should be in the list
|
||||
self.assertTrue(len(kwargs["mcp_servers"]) > 0)
|
||||
# First server should have a name attribute
|
||||
self.assertTrue(hasattr(kwargs["mcp_servers"][0], "name"))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue