recursive-agent 0.6.0

A minimal, orthogonal, self-improving coding agent kernel in Rust
Documentation
"""Unit tests for RecursiveClient using mocked HTTP responses."""

import unittest
from unittest.mock import MagicMock, patch

from recursive_client import RecursiveClient
from recursive_client.models import (
    MessageResponse,
    PlanProposedMessage,
    RunResponse,
    SessionDetail,
    SessionInfo,
    ToolInfo,
    UsageInfo,
)


class TestRecursiveClient(unittest.TestCase):
    def setUp(self):
        self.client = RecursiveClient("http://localhost:3000")

    @patch.object(RecursiveClient, "__init__", lambda self, *a, **kw: None)
    def _make_client(self):
        """Create a client with a mocked session."""
        client = RecursiveClient()
        client.base_url = "http://localhost:3000"
        client.session = MagicMock()
        return client

    def test_health(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.text = "ok"
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        result = client.health()

        self.assertEqual(result, "ok")
        client.session.get.assert_called_once_with("http://localhost:3000/health")
        mock_resp.raise_for_status.assert_called_once()

    def test_list_tools(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = [
            {
                "name": "shell",
                "description": "Run a shell command",
                "parameters": {"command": {"type": "string"}},
            },
            {
                "name": "read_file",
                "description": "Read a file",
                "parameters": {"path": {"type": "string"}},
            },
        ]
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        tools = client.list_tools()

        self.assertEqual(len(tools), 2)
        self.assertIsInstance(tools[0], ToolInfo)
        self.assertEqual(tools[0].name, "shell")
        self.assertEqual(tools[1].name, "read_file")
        client.session.get.assert_called_once_with("http://localhost:3000/tools")

    def test_run(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "status": "success",
            "finish_reason": "Complete",
            "messages": [{"role": "assistant", "content": "Done."}],
            "usage": {"total_steps": 3, "total_tokens": 1500},
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        result = client.run("Write hello.txt", max_steps=10)

        self.assertIsInstance(result, RunResponse)
        self.assertEqual(result.status, "success")
        self.assertEqual(result.finish_reason, "Complete")
        self.assertEqual(len(result.messages), 1)
        self.assertIsInstance(result.usage, UsageInfo)
        self.assertEqual(result.usage.total_steps, 3)
        self.assertEqual(result.usage.total_tokens, 1500)
        client.session.post.assert_called_once_with(
            "http://localhost:3000/run",
            json={"goal": "Write hello.txt", "max_steps": 10},
        )

    def test_create_session(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "id": "abc123",
            "created_at": "2025-01-01T00:00:00Z",
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        session_id = client.create_session(system_prompt="Be helpful.")

        self.assertEqual(session_id, "abc123")
        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions",
            json={"system_prompt": "Be helpful."},
        )

    def test_send_message(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "role": "assistant",
            "content": "Hello! How can I help?",
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        result = client.send_message("abc123", "Hi there")

        self.assertIsInstance(result, MessageResponse)
        self.assertEqual(result.role, "assistant")
        self.assertEqual(result.content, "Hello! How can I help?")
        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions/abc123/messages",
            json={"content": "Hi there"},
        )

    def test_list_sessions(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = [
            {"id": "s1", "created_at": "2025-01-01T00:00:00Z", "message_count": 5},
            {"id": "s2", "created_at": "2025-01-02T00:00:00Z", "message_count": 2},
        ]
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        sessions = client.list_sessions()

        self.assertEqual(len(sessions), 2)
        self.assertIsInstance(sessions[0], SessionInfo)
        self.assertEqual(sessions[0].id, "s1")
        self.assertEqual(sessions[0].message_count, 5)

    def test_get_session(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "id": "s1",
            "created_at": "2025-01-01T00:00:00Z",
            "messages": [
                {"role": "user", "content": "hi"},
                {"role": "assistant", "content": "hello"},
            ],
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        detail = client.get_session("s1")

        self.assertEqual(detail.id, "s1")
        self.assertEqual(len(detail.messages), 2)
        client.session.get.assert_called_once_with(
            "http://localhost:3000/sessions/s1"
        )

    def test_delete_session(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.raise_for_status = MagicMock()
        client.session.delete.return_value = mock_resp

        client.delete_session("s1")

        client.session.delete.assert_called_once_with(
            "http://localhost:3000/sessions/s1"
        )
        mock_resp.raise_for_status.assert_called_once()

    def test_get_session_with_plan_pending_fields(self):
        """SessionDetail should parse status and pending_plan from response."""
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "id": "s1",
            "created_at": "2026-01-01T00:00:00Z",
            "messages": [],
            "status": "plan_pending_approval",
            "pending_plan": "Step 1: read\nStep 2: write",
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        detail = client.get_session("s1")

        self.assertEqual(detail.status, "plan_pending_approval")
        self.assertEqual(detail.pending_plan, "Step 1: read\nStep 2: write")

    def test_get_session_defaults_to_idle_when_status_absent(self):
        """SessionDetail.status defaults to 'idle' for old API responses."""
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {
            "id": "s1",
            "created_at": "2026-01-01T00:00:00Z",
            "messages": [],
        }
        mock_resp.raise_for_status = MagicMock()
        client.session.get.return_value = mock_resp

        detail = client.get_session("s1")

        self.assertEqual(detail.status, "idle")
        self.assertIsNone(detail.pending_plan)

    def test_approve_plan_calls_correct_endpoint(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"status": "approved", "session_id": "s1"}
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        result = client.approve_plan("s1")

        self.assertEqual(result["status"], "approved")
        self.assertEqual(result["session_id"], "s1")
        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions/s1/plan/confirm",
            json={},
        )
        mock_resp.raise_for_status.assert_called_once()

    def test_approve_plan_with_edits_sends_edits(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"status": "approved", "session_id": "s1"}
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        client.approve_plan("s1", edits="Revised plan text")

        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions/s1/plan/confirm",
            json={"edits": "Revised plan text"},
        )

    def test_reject_plan_calls_correct_endpoint(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"status": "rejected", "session_id": "s1"}
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        result = client.reject_plan("s1", reason="not detailed enough")

        self.assertEqual(result["status"], "rejected")
        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions/s1/plan/reject",
            json={"reason": "not detailed enough"},
        )
        mock_resp.raise_for_status.assert_called_once()

    def test_reject_plan_default_empty_reason(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.json.return_value = {"status": "rejected", "session_id": "s1"}
        mock_resp.raise_for_status = MagicMock()
        client.session.post.return_value = mock_resp

        client.reject_plan("s1")

        client.session.post.assert_called_once_with(
            "http://localhost:3000/sessions/s1/plan/reject",
            json={"reason": ""},
        )

    def test_plan_proposed_message_dataclass(self):
        msg = PlanProposedMessage(plan="Do the thing", session_id="s42")
        self.assertEqual(msg.type, "plan_proposed")
        self.assertEqual(msg.plan, "Do the thing")
        self.assertEqual(msg.session_id, "s42")

    def test_plan_proposed_message_defaults(self):
        msg = PlanProposedMessage()
        self.assertEqual(msg.type, "plan_proposed")
        self.assertEqual(msg.plan, "")
        self.assertEqual(msg.session_id, "")


if __name__ == "__main__":
    unittest.main()