|
| 1 | +"""E2E tests for MemoryService against real ECS + LLMOps endpoints. |
| 2 | +
|
| 3 | +Prerequisites: |
| 4 | + uipath auth --alpha # sets UIPATH_URL + UIPATH_ACCESS_TOKEN |
| 5 | + export UIPATH_FOLDER_KEY=... # folder GUID with agent memory enabled |
| 6 | +
|
| 7 | +Run: |
| 8 | + cd packages/uipath-platform |
| 9 | + uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v |
| 10 | +""" |
| 11 | + |
| 12 | +import os |
| 13 | +import uuid |
| 14 | + |
| 15 | +import httpx |
| 16 | +import pytest |
| 17 | + |
| 18 | +from uipath.platform import UiPath |
| 19 | +from uipath.platform.errors import EnrichedException |
| 20 | +from uipath.platform.memory import ( |
| 21 | + EpisodicMemoryIndex, |
| 22 | + EpisodicMemoryListResponse, |
| 23 | + MemoryIngestResponse, |
| 24 | + MemorySearchRequest, |
| 25 | + MemorySearchResponse, |
| 26 | + SearchField, |
| 27 | + SearchMode, |
| 28 | + SearchSettings, |
| 29 | +) |
| 30 | + |
| 31 | +pytestmark = pytest.mark.e2e |
| 32 | + |
| 33 | + |
| 34 | +def _require_env(name: str) -> str: |
| 35 | + value = os.environ.get(name) |
| 36 | + if not value: |
| 37 | + pytest.skip(f"Environment variable {name} is not set") |
| 38 | + return value |
| 39 | + |
| 40 | + |
| 41 | +@pytest.fixture(scope="module") |
| 42 | +def sdk() -> UiPath: |
| 43 | + """Create a real UiPath client from env vars.""" |
| 44 | + _require_env("UIPATH_URL") |
| 45 | + _require_env("UIPATH_ACCESS_TOKEN") |
| 46 | + return UiPath() |
| 47 | + |
| 48 | + |
| 49 | +@pytest.fixture(scope="module") |
| 50 | +def folder_key() -> str: |
| 51 | + return _require_env("UIPATH_FOLDER_KEY") |
| 52 | + |
| 53 | + |
| 54 | +@pytest.fixture(scope="module") |
| 55 | +def base_url() -> str: |
| 56 | + return _require_env("UIPATH_URL") |
| 57 | + |
| 58 | + |
| 59 | +@pytest.fixture(scope="module") |
| 60 | +def access_token() -> str: |
| 61 | + return _require_env("UIPATH_ACCESS_TOKEN") |
| 62 | + |
| 63 | + |
| 64 | +@pytest.fixture(scope="module") |
| 65 | +def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201 |
| 66 | + """Create a test memory index and clean it up after all tests.""" |
| 67 | + unique_name = f"sdk-e2e-test-{uuid.uuid4().hex[:8]}" |
| 68 | + index = sdk.memory.create( |
| 69 | + name=unique_name, |
| 70 | + description="Created by E2E test — safe to delete", |
| 71 | + folder_key=folder_key, |
| 72 | + ) |
| 73 | + yield index |
| 74 | + # Cleanup |
| 75 | + try: |
| 76 | + sdk.memory.delete_index(key=index.id, folder_key=folder_key) |
| 77 | + except Exception: |
| 78 | + pass |
| 79 | + |
| 80 | + |
| 81 | +class TestMemoryServiceE2E: |
| 82 | + """E2E tests for MemoryService lifecycle. |
| 83 | +
|
| 84 | + Requires: UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY |
| 85 | + """ |
| 86 | + |
| 87 | + # ── Index CRUD (ECS) ────────────────────────────────────────── |
| 88 | + |
| 89 | + def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None: |
| 90 | + """Verify index creation returns a well-formed EpisodicMemoryIndex.""" |
| 91 | + assert memory_index.id, "Index ID should be set" |
| 92 | + assert memory_index.name.startswith("sdk-e2e-test-") |
| 93 | + assert memory_index.folder_key, "Folder key should be populated" |
| 94 | + assert memory_index.memories_count == 0 |
| 95 | + |
| 96 | + def test_get_index( |
| 97 | + self, |
| 98 | + sdk: UiPath, |
| 99 | + memory_index: EpisodicMemoryIndex, |
| 100 | + folder_key: str, |
| 101 | + ) -> None: |
| 102 | + """Verify we can retrieve the index by key.""" |
| 103 | + fetched = sdk.memory.get(key=memory_index.id, folder_key=folder_key) |
| 104 | + assert fetched.id == memory_index.id |
| 105 | + assert fetched.name == memory_index.name |
| 106 | + |
| 107 | + def test_list_indexes( |
| 108 | + self, |
| 109 | + sdk: UiPath, |
| 110 | + memory_index: EpisodicMemoryIndex, |
| 111 | + folder_key: str, |
| 112 | + ) -> None: |
| 113 | + """Verify list with OData filter returns our index.""" |
| 114 | + result = sdk.memory.list( |
| 115 | + filter=f"Name eq '{memory_index.name}'", |
| 116 | + folder_key=folder_key, |
| 117 | + ) |
| 118 | + assert isinstance(result, EpisodicMemoryListResponse) |
| 119 | + names = [idx.name for idx in result.value] |
| 120 | + assert memory_index.name in names |
| 121 | + |
| 122 | + # ── Search (LLMOps) ────────────────────────────────────────── |
| 123 | + |
| 124 | + def test_search_empty_index( |
| 125 | + self, |
| 126 | + sdk: UiPath, |
| 127 | + memory_index: EpisodicMemoryIndex, |
| 128 | + folder_key: str, |
| 129 | + ) -> None: |
| 130 | + """Search an empty index — should return empty results and systemPromptInjection.""" |
| 131 | + request = MemorySearchRequest( |
| 132 | + fields=[ |
| 133 | + SearchField( |
| 134 | + key_path=["input"], |
| 135 | + value="test query", |
| 136 | + ) |
| 137 | + ], |
| 138 | + settings=SearchSettings( |
| 139 | + threshold=0.0, |
| 140 | + result_count=5, |
| 141 | + search_mode=SearchMode.Hybrid, |
| 142 | + ), |
| 143 | + definition_system_prompt="You are a helpful assistant.", |
| 144 | + ) |
| 145 | + result = sdk.memory.search( |
| 146 | + memory_space_id=memory_index.id, |
| 147 | + request=request, |
| 148 | + folder_key=folder_key, |
| 149 | + ) |
| 150 | + assert isinstance(result, MemorySearchResponse) |
| 151 | + assert isinstance(result.results, list) |
| 152 | + assert isinstance(result.metadata, dict) |
| 153 | + # systemPromptInjection should be a string (possibly empty for no results) |
| 154 | + assert isinstance(result.system_prompt_injection, str) |
| 155 | + |
| 156 | + # ── Full ingest lifecycle (LLMOps) ──────────────────────────── |
| 157 | + |
| 158 | + def test_ingest_and_search( |
| 159 | + self, |
| 160 | + sdk: UiPath, |
| 161 | + memory_index: EpisodicMemoryIndex, |
| 162 | + folder_key: str, |
| 163 | + base_url: str, |
| 164 | + access_token: str, |
| 165 | + ) -> None: |
| 166 | + """Full lifecycle: create feedback → ingest → search → verify match.""" |
| 167 | + # Step 1: Create a synthetic feedback via LLMOps API directly |
| 168 | + # (MemoryService doesn't have a feedback API — this is test scaffolding) |
| 169 | + trace_id = str(uuid.uuid4()) |
| 170 | + span_id = str(uuid.uuid4()) |
| 171 | + user_id = str(uuid.uuid4()) |
| 172 | + |
| 173 | + feedback_payload = { |
| 174 | + "traceId": trace_id, |
| 175 | + "spanId": span_id, |
| 176 | + "userId": user_id, |
| 177 | + "isPositive": True, |
| 178 | + "isOutput": False, |
| 179 | + "isAgentError": False, |
| 180 | + "isAgentPlanExecution": False, |
| 181 | + "memorySpaceId": memory_index.id, |
| 182 | + "memorySpaceName": memory_index.name, |
| 183 | + "attributes": '{"input": "What is the capital of France?", "output": "Paris"}', |
| 184 | + } |
| 185 | + |
| 186 | + with httpx.Client( |
| 187 | + base_url=base_url, |
| 188 | + headers={"Authorization": f"Bearer {access_token}"}, |
| 189 | + timeout=30.0, |
| 190 | + ) as client: |
| 191 | + resp = client.post( |
| 192 | + "/llmops_/api/Agent/feedback", |
| 193 | + json=feedback_payload, |
| 194 | + ) |
| 195 | + # If feedback creation fails (e.g. LLMOps not available), |
| 196 | + # skip gracefully rather than fail the whole suite |
| 197 | + if resp.status_code >= 400: |
| 198 | + pytest.skip( |
| 199 | + f"Could not create feedback (HTTP {resp.status_code}): {resp.text}" |
| 200 | + ) |
| 201 | + feedback_data = resp.json() |
| 202 | + |
| 203 | + feedback_id = feedback_data.get("id") or feedback_data.get("feedbackId") |
| 204 | + assert feedback_id, f"No feedback ID in response: {feedback_data}" |
| 205 | + |
| 206 | + # Step 2: Ingest via MemoryService (LLMOps) |
| 207 | + ingest_result = sdk.memory.ingest( |
| 208 | + memory_space_id=memory_index.id, |
| 209 | + feedback_id=feedback_id, |
| 210 | + memory_space_name=memory_index.name, |
| 211 | + folder_key=folder_key, |
| 212 | + ) |
| 213 | + assert isinstance(ingest_result, MemoryIngestResponse) |
| 214 | + assert ingest_result.memory_item_id, "Should return a memory item ID" |
| 215 | + |
| 216 | + # Step 3: Search to find the ingested memory |
| 217 | + search_request = MemorySearchRequest( |
| 218 | + fields=[ |
| 219 | + SearchField( |
| 220 | + key_path=["input"], |
| 221 | + value="What is the capital of France?", |
| 222 | + ) |
| 223 | + ], |
| 224 | + settings=SearchSettings( |
| 225 | + threshold=0.0, |
| 226 | + result_count=5, |
| 227 | + search_mode=SearchMode.Hybrid, |
| 228 | + ), |
| 229 | + definition_system_prompt="You are a helpful assistant.", |
| 230 | + ) |
| 231 | + search_result = sdk.memory.search( |
| 232 | + memory_space_id=memory_index.id, |
| 233 | + request=search_request, |
| 234 | + folder_key=folder_key, |
| 235 | + ) |
| 236 | + assert isinstance(search_result, MemorySearchResponse) |
| 237 | + assert isinstance(search_result.system_prompt_injection, str) |
| 238 | + # Ingestion may be async — we verify the response shape is valid |
| 239 | + # even if results aren't immediately available |
| 240 | + assert isinstance(search_result.results, list) |
| 241 | + |
| 242 | + # ── Delete lifecycle ────────────────────────────────────────── |
| 243 | + |
| 244 | + def test_delete_index( |
| 245 | + self, |
| 246 | + sdk: UiPath, |
| 247 | + folder_key: str, |
| 248 | + ) -> None: |
| 249 | + """Verify index deletion works (uses a separate index to not break other tests).""" |
| 250 | + temp_name = f"sdk-e2e-delete-{uuid.uuid4().hex[:8]}" |
| 251 | + temp_index = sdk.memory.create( |
| 252 | + name=temp_name, |
| 253 | + description="Temp index for delete test", |
| 254 | + folder_key=folder_key, |
| 255 | + ) |
| 256 | + # Delete it |
| 257 | + sdk.memory.delete_index(key=temp_index.id, folder_key=folder_key) |
| 258 | + |
| 259 | + # Verify it's gone — GET should raise |
| 260 | + with pytest.raises(EnrichedException): |
| 261 | + sdk.memory.get(key=temp_index.id, folder_key=folder_key) |
0 commit comments