import unittest from unittest.mock import patch, MagicMock, ANY import json import subprocess import io import sys import os # Ensure the project root is in sys.path to resolve imports correctly project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if project_root not in sys.path: sys.path.append(project_root) # Import the class to be tested from gemini_cli_adapter import GeminiCliAdapter class TestGeminiCliAdapterParity(unittest.TestCase): def setUp(self) -> None: """Set up a fresh adapter instance and reset session state for each test.""" # Patch session_logger to prevent file operations during tests self.session_logger_patcher = patch('gemini_cli_adapter.session_logger') self.mock_session_logger = self.session_logger_patcher.start() self.adapter = GeminiCliAdapter(binary_path="gemini") self.adapter.session_id = None self.adapter.last_usage = None self.adapter.last_latency = 0.0 def tearDown(self) -> None: self.session_logger_patcher.stop() @patch('subprocess.Popen') def test_count_tokens_uses_estimation(self, mock_popen: MagicMock) -> None: """ Test that count_tokens uses character-based estimation. """ contents_to_count = ["This is the first line.", "This is the second line."] expected_chars = len("\n".join(contents_to_count)) expected_tokens = expected_chars // 4 token_count = self.adapter.count_tokens(contents=contents_to_count) self.assertEqual(token_count, expected_tokens) # Verify that NO subprocess was started for counting mock_popen.assert_not_called() @patch('subprocess.Popen') def test_send_with_safety_settings_no_flags_added(self, mock_popen: MagicMock) -> None: """ Test that the send method does NOT add --safety flags when safety_settings are provided, as this functionality is no longer supported via CLI flags. """ process_mock = MagicMock() mock_stdout_content = [json.dumps({"type": "result", "usage": {}}) + "\n", ""] process_mock.stdout.readline.side_effect = mock_stdout_content process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 mock_popen.return_value = process_mock message_content = "User's prompt here." safety_settings = [ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"} ] self.adapter.send(message=message_content, safety_settings=safety_settings) args, kwargs = mock_popen.call_args command = args[0] # Verify that no --safety flags were added to the command self.assertNotIn("--safety", command) # Verify that the message was passed correctly via stdin # We might need to wait a tiny bit for the thread, or just check if it was called # In most cases it will be called by the time send() returns because of wait() process_mock.stdin.write.assert_called_with(message_content) @patch('subprocess.Popen') def test_send_without_safety_settings_no_flags(self, mock_popen: MagicMock) -> None: """ Test that when safety_settings is None or an empty list, no --safety flags are added. """ process_mock = MagicMock() mock_stdout_content = [json.dumps({"type": "result", "usage": {}}) + "\n", ""] process_mock.stdout.readline.side_effect = mock_stdout_content process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 mock_popen.return_value = process_mock message_content = "Another prompt." self.adapter.send(message=message_content, safety_settings=None) args_none, _ = mock_popen.call_args self.assertNotIn("--safety", args_none[0]) mock_popen.reset_mock() # Reset side effects for the second call process_mock.stdout.readline.side_effect = [json.dumps({"type": "result", "usage": {}}) + "\n", ""] self.adapter.send(message=message_content, safety_settings=[]) args_empty, _ = mock_popen.call_args self.assertNotIn("--safety", args_empty[0]) @patch('subprocess.Popen') def test_send_with_system_instruction_prepended_to_stdin(self, mock_popen: MagicMock) -> None: """ Test that the send method prepends the system instruction to the prompt sent via stdin, and does NOT add a --system flag to the command. """ process_mock = MagicMock() mock_stdout_content = [json.dumps({"type": "result", "usage": {}}) + "\n", ""] process_mock.stdout.readline.side_effect = mock_stdout_content process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 mock_popen.return_value = process_mock message_content = "User's prompt here." system_instruction_text = "Some instruction" expected_input = f"{system_instruction_text}\n\n{message_content}" self.adapter.send(message=message_content, system_instruction=system_instruction_text) args, kwargs = mock_popen.call_args command = args[0] # Verify that the system instruction was prepended to the input sent to write process_mock.stdin.write.assert_called_with(expected_input) # Verify that no --system flag was added to the command self.assertNotIn("--system", command) @patch('subprocess.Popen') def test_send_with_model_parameter(self, mock_popen: MagicMock) -> None: """ Test that the send method correctly adds the -m flag when a model is specified. """ process_mock = MagicMock() mock_stdout_content = [json.dumps({"type": "result", "usage": {}}) + "\n", ""] process_mock.stdout.readline.side_effect = mock_stdout_content process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 mock_popen.return_value = process_mock message_content = "User's prompt here." model_name = "gemini-1.5-flash" expected_command_part = f'-m "{model_name}"' self.adapter.send(message=message_content, model=model_name) args, kwargs = mock_popen.call_args command = args[0] # Verify that the -m flag was added to the command self.assertIn(expected_command_part, command) # Verify that the message was passed correctly via stdin process_mock.stdin.write.assert_called_with(message_content) @patch('subprocess.Popen') def test_send_parses_tool_calls_from_streaming_json(self, mock_popen: MagicMock) -> None: """ Test that tool_use messages in the streaming JSON are correctly parsed. """ process_mock = MagicMock() mock_stdout_content = [ json.dumps({"type": "init", "session_id": "session-123"}) + "\n", json.dumps({"type": "chunk", "text": "I will call a tool. "}) + "\n", json.dumps({"type": "tool_use", "name": "get_weather", "args": {"location": "London"}, "id": "call-456"}) + "\n", json.dumps({"type": "result", "usage": {"total_tokens": 100}}) + "\n", "" ] process_mock.stdout.readline.side_effect = mock_stdout_content process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 mock_popen.return_value = process_mock result = self.adapter.send(message="What is the weather?") self.assertEqual(result["text"], "I will call a tool. ") self.assertEqual(len(result["tool_calls"]), 1) self.assertEqual(result["tool_calls"][0]["name"], "get_weather") self.assertEqual(result["tool_calls"][0]["args"], {"location": "London"}) self.assertEqual(self.adapter.session_id, "session-123") self.assertEqual(self.adapter.last_usage, {"total_tokens": 100}) if __name__ == '__main__': unittest.main()