import unittest from typing import Any from unittest.mock import patch, MagicMock import json import subprocess import io import sys import os # Ensure the project root is in sys.path to resolve imports correctly sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from gemini_cli_adapter import GeminiCliAdapter class TestGeminiCliAdapter(unittest.TestCase): def setUp(self) -> None: self.adapter = GeminiCliAdapter(binary_path="gemini") @patch('subprocess.Popen') def test_send_starts_subprocess_with_correct_args(self, mock_popen: Any) -> None: """ Verify that send(message) correctly starts the subprocess with --output-format stream-json and the provided message via stdin. """ # Setup mock process with a minimal valid JSONL termination process_mock = MagicMock() jsonl_output = [json.dumps({"type": "result", "usage": {}}) + "\n"] process_mock.stdout.readline.side_effect = jsonl_output + [''] process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 process_mock.wait.return_value = 0 mock_popen.return_value = process_mock message = "Hello Gemini CLI" self.adapter.send(message) # Verify subprocess.Popen call mock_popen.assert_called_once() args, kwargs = mock_popen.call_args cmd = args[0] # Check mandatory CLI components self.assertIn("gemini", cmd) self.assertIn("--output-format", cmd) self.assertIn("stream-json", cmd) # Message should NOT be in cmd now self.assertNotIn(message, cmd) # Verify message was written to stdin process_mock.stdin.write.assert_called_with(message) # Check process configuration self.assertEqual(kwargs.get('stdout'), subprocess.PIPE) self.assertEqual(kwargs.get('stdin'), subprocess.PIPE) self.assertEqual(kwargs.get('text'), True) @patch('subprocess.Popen') def test_send_parses_jsonl_output(self, mock_popen: Any) -> None: """ Verify that it correctly parses multiple JSONL 'message' events and returns the combined text. """ jsonl_output = [ json.dumps({"type": "message", "role": "model", "text": "The quick brown "}) + "\n", json.dumps({"type": "message", "role": "model", "text": "fox jumps."}) + "\n", json.dumps({"type": "result", "usage": {"prompt_tokens": 5, "candidates_tokens": 5}}) + "\n" ] process_mock = MagicMock() process_mock.stdout.readline.side_effect = jsonl_output + [''] process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 process_mock.wait.return_value = 0 mock_popen.return_value = process_mock result = self.adapter.send("test message") self.assertEqual(result["text"], "The quick brown fox jumps.") self.assertEqual(result["tool_calls"], []) @patch('subprocess.Popen') def test_send_handles_tool_use_events(self, mock_popen: Any) -> None: """ Verify that it correctly handles 'tool_use' events in the stream by continuing to read until the final 'result' event. """ jsonl_output = [ json.dumps({"type": "message", "role": "assistant", "text": "Calling tool..."}) + "\n", json.dumps({"type": "tool_use", "name": "read_file", "args": {"path": "test.txt"}}) + "\n", json.dumps({"type": "message", "role": "assistant", "text": "\nFile read successfully."}) + "\n", json.dumps({"type": "result", "usage": {}}) + "\n" ] process_mock = MagicMock() process_mock.stdout.readline.side_effect = jsonl_output + [''] process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 process_mock.wait.return_value = 0 mock_popen.return_value = process_mock result = self.adapter.send("read test.txt") # Result should contain the combined text from all 'message' events self.assertEqual(result["text"], "Calling tool...\nFile read successfully.") self.assertEqual(len(result["tool_calls"]), 1) self.assertEqual(result["tool_calls"][0]["name"], "read_file") @patch('subprocess.Popen') def test_send_captures_usage_metadata(self, mock_popen: Any) -> None: """ Verify that usage data is extracted from the 'result' event. """ usage_data = {"total_tokens": 42} jsonl_output = [ json.dumps({"type": "message", "text": "Finalizing"}) + "\n", json.dumps({"type": "result", "usage": usage_data}) + "\n" ] process_mock = MagicMock() process_mock.stdout.readline.side_effect = jsonl_output + [''] process_mock.stderr.read.return_value = "" process_mock.poll.return_value = 0 process_mock.wait.return_value = 0 mock_popen.return_value = process_mock self.adapter.send("usage test") # Verify the usage was captured in the adapter instance self.assertEqual(self.adapter.last_usage, usage_data) if __name__ == '__main__': unittest.main()