|
| 1 | +"""Tool arguments must survive simulation regardless of their Python type. |
| 2 | +
|
| 3 | +A simulated tool call is serialized twice: once by ``LLMMocker`` to build the |
| 4 | +simulation prompt, and once by ``SimulateComponentService`` to POST the payload. |
| 5 | +Tool ``args_schema`` models may type a field as any Python type (``uuid.UUID``, |
| 6 | +``datetime``, ``Enum``, ...), and LangChain hands the tool the *validated* |
| 7 | +value, so both paths receive objects the stdlib JSON encoder rejects. |
| 8 | +""" |
| 9 | + |
| 10 | +import json |
| 11 | +import uuid |
| 12 | +from datetime import datetime, timezone |
| 13 | +from enum import Enum |
| 14 | +from typing import Any |
| 15 | +from unittest.mock import MagicMock, patch |
| 16 | + |
| 17 | +import pytest |
| 18 | +from _pytest.monkeypatch import MonkeyPatch |
| 19 | +from pytest_httpx import HTTPXMock |
| 20 | + |
| 21 | +from uipath.eval.mocks._mock_runtime import ( |
| 22 | + clear_execution_context, |
| 23 | + set_execution_context, |
| 24 | +) |
| 25 | +from uipath.eval.mocks._simulate_component_service import ( |
| 26 | + _create_simulate_component_service, |
| 27 | +) |
| 28 | +from uipath.eval.mocks._types import ( |
| 29 | + ComponentSimulationConfig, |
| 30 | + LLMMockingStrategy, |
| 31 | + MockingContext, |
| 32 | + SimulationStrategy, |
| 33 | + ToolSimulation, |
| 34 | +) |
| 35 | +from uipath.eval.mocks.mockable import mockable |
| 36 | + |
| 37 | +_mock_span_collector = MagicMock() |
| 38 | + |
| 39 | +_ATTACHMENT_ID = uuid.UUID("9b702dc7-4988-4fc0-ba81-08deeaade3da") |
| 40 | + |
| 41 | + |
| 42 | +class _Flavor(str, Enum): |
| 43 | + PDF = "pdf" |
| 44 | + |
| 45 | + |
| 46 | +class _Rank(Enum): |
| 47 | + """A plain Enum: not a str subclass, so json.dumps cannot encode it.""" |
| 48 | + |
| 49 | + HIGH = 1 |
| 50 | + |
| 51 | + |
| 52 | +def _llm_context(tool_name: str) -> MockingContext: |
| 53 | + return MockingContext( |
| 54 | + strategy=LLMMockingStrategy( |
| 55 | + prompt="simulate it", |
| 56 | + tools_to_simulate=[ToolSimulation(name=tool_name)], |
| 57 | + ), |
| 58 | + name="test-run", |
| 59 | + inputs={}, |
| 60 | + ) |
| 61 | + |
| 62 | + |
| 63 | +class TestLLMMockerArgSerialization: |
| 64 | + """LLMMocker builds its prompt with json.dumps over the raw invocation.""" |
| 65 | + |
| 66 | + def teardown_method(self): |
| 67 | + clear_execution_context() |
| 68 | + |
| 69 | + @pytest.mark.asyncio |
| 70 | + async def test_uuid_argument_does_not_break_prompt_generation(self): |
| 71 | + captured: dict[str, Any] = {} |
| 72 | + |
| 73 | + async def _fake_generate(llm, messages, **kwargs): |
| 74 | + captured["prompt"] = messages[0]["content"] |
| 75 | + return {"ok": True} |
| 76 | + |
| 77 | + @mockable() |
| 78 | + async def extraction_tool(**kwargs: Any) -> dict[str, Any]: |
| 79 | + raise NotImplementedError("must be simulated, never executed") |
| 80 | + |
| 81 | + set_execution_context( |
| 82 | + _llm_context("extraction_tool"), _mock_span_collector, "exec-uuid" |
| 83 | + ) |
| 84 | + with ( |
| 85 | + patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()), |
| 86 | + patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()), |
| 87 | + patch( |
| 88 | + "uipath.eval.mocks._llm_mocker.generate_structured_output", |
| 89 | + _fake_generate, |
| 90 | + ), |
| 91 | + ): |
| 92 | + result = await extraction_tool( |
| 93 | + id=_ATTACHMENT_ID, |
| 94 | + full_name="PO_234.pdf", |
| 95 | + mime_type="application/pdf", |
| 96 | + ) |
| 97 | + |
| 98 | + assert result == {"ok": True} |
| 99 | + # The UUID must reach the prompt in its string form. |
| 100 | + assert str(_ATTACHMENT_ID) in captured["prompt"] |
| 101 | + |
| 102 | + @pytest.mark.asyncio |
| 103 | + async def test_other_non_json_native_arguments_are_serialized(self): |
| 104 | + captured: dict[str, Any] = {} |
| 105 | + |
| 106 | + async def _fake_generate(llm, messages, **kwargs): |
| 107 | + captured["prompt"] = messages[0]["content"] |
| 108 | + return {"ok": True} |
| 109 | + |
| 110 | + @mockable() |
| 111 | + async def typed_tool(**kwargs: Any) -> dict[str, Any]: |
| 112 | + raise NotImplementedError() |
| 113 | + |
| 114 | + set_execution_context( |
| 115 | + _llm_context("typed_tool"), _mock_span_collector, "exec-typed" |
| 116 | + ) |
| 117 | + with ( |
| 118 | + patch("uipath.eval.mocks._llm_mocker.UiPath", MagicMock()), |
| 119 | + patch("uipath.eval.mocks._llm_mocker.UiPathLlmChatService", MagicMock()), |
| 120 | + patch( |
| 121 | + "uipath.eval.mocks._llm_mocker.generate_structured_output", |
| 122 | + _fake_generate, |
| 123 | + ), |
| 124 | + ): |
| 125 | + await typed_tool( |
| 126 | + when=datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc), |
| 127 | + flavor=_Flavor.PDF, |
| 128 | + rank=_Rank.HIGH, |
| 129 | + tags={"alpha", "beta"}, |
| 130 | + ) |
| 131 | + |
| 132 | + prompt = captured["prompt"] |
| 133 | + assert "2026-07-27T12:39:59" in prompt |
| 134 | + assert "pdf" in prompt |
| 135 | + # A plain (non-str) Enum must be reduced to its value. |
| 136 | + assert '"rank": 1' in prompt |
| 137 | + # Sets become lists; assert on members so the check stays order-independent. |
| 138 | + assert '"alpha"' in prompt |
| 139 | + assert '"beta"' in prompt |
| 140 | + |
| 141 | + |
| 142 | +class TestSimulateComponentPayloadSerialization: |
| 143 | + """The simulate-component payload is encoded by httpx with a bare json.dumps.""" |
| 144 | + |
| 145 | + def teardown_method(self): |
| 146 | + clear_execution_context() |
| 147 | + |
| 148 | + @pytest.mark.asyncio |
| 149 | + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) |
| 150 | + async def test_uuid_in_payload_is_sent_as_string( |
| 151 | + self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch |
| 152 | + ): |
| 153 | + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") |
| 154 | + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") |
| 155 | + httpx_mock.add_response( |
| 156 | + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", |
| 157 | + method="POST", |
| 158 | + json={"status": 1, "simulatedOutput": "result"}, |
| 159 | + ) |
| 160 | + |
| 161 | + service = _create_simulate_component_service() |
| 162 | + result = await service.simulate( |
| 163 | + { |
| 164 | + "componentId": "extraction_tool", |
| 165 | + "input": { |
| 166 | + "args": [], |
| 167 | + "kwargs": { |
| 168 | + "id": _ATTACHMENT_ID, |
| 169 | + "when": datetime(2026, 7, 27, 12, 39, 59, tzinfo=timezone.utc), |
| 170 | + "rank": _Rank.HIGH, |
| 171 | + "tags": {"alpha", "beta"}, |
| 172 | + }, |
| 173 | + }, |
| 174 | + } |
| 175 | + ) |
| 176 | + |
| 177 | + assert result == {"status": 1, "simulatedOutput": "result"} |
| 178 | + sent_kwargs = json.loads(httpx_mock.get_requests()[-1].read())["input"][ |
| 179 | + "kwargs" |
| 180 | + ] |
| 181 | + assert sent_kwargs["id"] == str(_ATTACHMENT_ID) |
| 182 | + assert sent_kwargs["when"].startswith("2026-07-27T12:39:59") |
| 183 | + assert sent_kwargs["rank"] == 1 |
| 184 | + assert sorted(sent_kwargs["tags"]) == ["alpha", "beta"] |
| 185 | + |
| 186 | + @pytest.mark.asyncio |
| 187 | + async def test_mocker_end_to_end_with_uuid_argument(self): |
| 188 | + captured: list[dict[str, Any]] = [] |
| 189 | + |
| 190 | + async def _capture(payload, **kwargs): |
| 191 | + captured.append(payload) |
| 192 | + return {"status": 1, "simulatedOutput": "ok"} |
| 193 | + |
| 194 | + svc_mock = MagicMock() |
| 195 | + svc_mock.simulate = _capture |
| 196 | + |
| 197 | + @mockable() |
| 198 | + async def extraction_tool(**kwargs: Any) -> str: |
| 199 | + raise NotImplementedError() |
| 200 | + |
| 201 | + context = MockingContext( |
| 202 | + strategy=None, |
| 203 | + name="test-run", |
| 204 | + inputs={}, |
| 205 | + workload_id="wl-1", |
| 206 | + components=[ |
| 207 | + ComponentSimulationConfig( |
| 208 | + component_id="extraction_tool", |
| 209 | + simulation_strategy=SimulationStrategy.LLM, |
| 210 | + ) |
| 211 | + ], |
| 212 | + ) |
| 213 | + set_execution_context(context, _mock_span_collector, "exec-e2e") |
| 214 | + with patch( |
| 215 | + "uipath.eval.mocks._simulate_component_mocker._create_simulate_component_service", |
| 216 | + return_value=svc_mock, |
| 217 | + ): |
| 218 | + result = await extraction_tool(id=_ATTACHMENT_ID) |
| 219 | + |
| 220 | + assert result == "ok" |
| 221 | + assert captured[0]["input"]["kwargs"]["id"] == _ATTACHMENT_ID |
| 222 | + |
| 223 | + |
| 224 | +class TestSimulateComponentServiceUnchangedBehaviour: |
| 225 | + """Normalization must not alter payloads that already serialized cleanly.""" |
| 226 | + |
| 227 | + @pytest.mark.asyncio |
| 228 | + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) |
| 229 | + async def test_json_native_payload_is_unchanged( |
| 230 | + self, httpx_mock: HTTPXMock, monkeypatch: MonkeyPatch |
| 231 | + ): |
| 232 | + monkeypatch.setenv("UIPATH_URL", "https://example.com/myorg/mytenant") |
| 233 | + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") |
| 234 | + httpx_mock.add_response( |
| 235 | + url="https://example.com/myorg/mytenant/agentsruntime_/api/execution/simulations/simulate-component", |
| 236 | + method="POST", |
| 237 | + json={"status": 1, "simulatedOutput": "result"}, |
| 238 | + ) |
| 239 | + |
| 240 | + payload: dict[str, Any] = { |
| 241 | + "componentId": "my_tool", |
| 242 | + "componentType": "tool", |
| 243 | + "input": {"args": [1, "two", True, None], "kwargs": {"nested": {"a": 1.5}}}, |
| 244 | + "behaviors": None, |
| 245 | + "simulationStrategy": 0, |
| 246 | + } |
| 247 | + service = _create_simulate_component_service() |
| 248 | + await service.simulate(payload) |
| 249 | + |
| 250 | + sent = json.loads(httpx_mock.get_requests()[-1].read()) |
| 251 | + assert sent == payload |
0 commit comments