From 1abc7bd3dc622086377cf2a82c6f01e3319b3465 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 21 Jul 2026 14:19:58 +0300 Subject: [PATCH] test: raise integrations package coverage above 80% --- .../tests/test_chat_clients.py | 119 ++++++ .../tests/test_chat_common.py | 57 +++ .../tests/test_chat_module.py | 40 ++ .../tests/test_cli_new.py | 37 ++ .../tests/test_loader.py | 108 +++++ .../tests/test_messages.py | 119 ++++++ .../uipath-google-adk/tests/test_cli_new.py | 32 ++ .../uipath-google-adk/tests/test_common.py | 61 +++ .../uipath-google-adk/tests/test_factory.py | 132 ++++++ .../uipath-google-adk/tests/test_gemini.py | 46 +++ .../tests/test_lazy_imports.py | 43 ++ .../uipath-google-adk/tests/test_messages.py | 181 +++++++++ .../uipath-google-adk/tests/test_openai.py | 382 ++++++++++++++++++ .../uipath-google-adk/tests/test_runtime.py | 218 ++++++++++ packages/uipath-llamaindex/pyproject.toml | 7 + .../tests/embeddings/__init__.py | 0 .../tests/embeddings/test_openai_embedding.py | 41 ++ .../uipath-llamaindex/tests/llms/__init__.py | 0 .../tests/llms/test_bedrock.py | 80 ++++ .../tests/llms/test_openai.py | 71 ++++ .../tests/llms/test_supported_models.py | 20 + .../tests/llms/test_vertex.py | 112 +++++ .../tests/models/__init__.py | 0 .../tests/models/test_events.py | 29 ++ .../tests/query_engines/__init__.py | 0 .../test_context_grounding_query_engine.py | 47 +++ .../tests/retrievers/__init__.py | 0 .../test_context_grounding_retriever.py | 60 +++ .../tests/runtime/test_breakpoints.py | 52 +++ .../tests/runtime/test_config.py | 49 +++ .../tests/runtime/test_errors.py | 21 + .../tests/runtime/test_telemetry.py | 52 +++ .../tests/runtime/test_workflow_loader.py | 96 +++++ packages/uipath-llamaindex/uv.lock | 12 + .../tests/test_agent_loader.py | 90 +++++ .../tests/test_chat_openai.py | 74 ++++ .../tests/test_cli_new.py | 43 ++ .../uipath-openai-agents/tests/test_config.py | 44 ++ .../tests/test_factory.py | 108 +++++ .../tests/test_placeholder.py | 6 - .../tests/test_runtime.py | 164 ++++++++ .../uipath-pydantic-ai/tests/test_chat.py | 149 +++++++ .../uipath-pydantic-ai/tests/test_cli_new.py | 39 ++ .../tests/test_factory_runtime.py | 120 ++++++ .../uipath-pydantic-ai/tests/test_loader.py | 187 +++++++++ 45 files changed, 3342 insertions(+), 6 deletions(-) create mode 100644 packages/uipath-agent-framework/tests/test_chat_clients.py create mode 100644 packages/uipath-agent-framework/tests/test_chat_common.py create mode 100644 packages/uipath-agent-framework/tests/test_chat_module.py create mode 100644 packages/uipath-agent-framework/tests/test_cli_new.py create mode 100644 packages/uipath-agent-framework/tests/test_loader.py create mode 100644 packages/uipath-agent-framework/tests/test_messages.py create mode 100644 packages/uipath-google-adk/tests/test_cli_new.py create mode 100644 packages/uipath-google-adk/tests/test_common.py create mode 100644 packages/uipath-google-adk/tests/test_factory.py create mode 100644 packages/uipath-google-adk/tests/test_gemini.py create mode 100644 packages/uipath-google-adk/tests/test_lazy_imports.py create mode 100644 packages/uipath-google-adk/tests/test_messages.py create mode 100644 packages/uipath-google-adk/tests/test_openai.py create mode 100644 packages/uipath-google-adk/tests/test_runtime.py create mode 100644 packages/uipath-llamaindex/tests/embeddings/__init__.py create mode 100644 packages/uipath-llamaindex/tests/embeddings/test_openai_embedding.py create mode 100644 packages/uipath-llamaindex/tests/llms/__init__.py create mode 100644 packages/uipath-llamaindex/tests/llms/test_bedrock.py create mode 100644 packages/uipath-llamaindex/tests/llms/test_openai.py create mode 100644 packages/uipath-llamaindex/tests/llms/test_supported_models.py create mode 100644 packages/uipath-llamaindex/tests/llms/test_vertex.py create mode 100644 packages/uipath-llamaindex/tests/models/__init__.py create mode 100644 packages/uipath-llamaindex/tests/models/test_events.py create mode 100644 packages/uipath-llamaindex/tests/query_engines/__init__.py create mode 100644 packages/uipath-llamaindex/tests/query_engines/test_context_grounding_query_engine.py create mode 100644 packages/uipath-llamaindex/tests/retrievers/__init__.py create mode 100644 packages/uipath-llamaindex/tests/retrievers/test_context_grounding_retriever.py create mode 100644 packages/uipath-llamaindex/tests/runtime/test_breakpoints.py create mode 100644 packages/uipath-llamaindex/tests/runtime/test_config.py create mode 100644 packages/uipath-llamaindex/tests/runtime/test_errors.py create mode 100644 packages/uipath-llamaindex/tests/runtime/test_telemetry.py create mode 100644 packages/uipath-llamaindex/tests/runtime/test_workflow_loader.py create mode 100644 packages/uipath-openai-agents/tests/test_agent_loader.py create mode 100644 packages/uipath-openai-agents/tests/test_chat_openai.py create mode 100644 packages/uipath-openai-agents/tests/test_cli_new.py create mode 100644 packages/uipath-openai-agents/tests/test_config.py create mode 100644 packages/uipath-openai-agents/tests/test_factory.py delete mode 100644 packages/uipath-openai-agents/tests/test_placeholder.py create mode 100644 packages/uipath-openai-agents/tests/test_runtime.py create mode 100644 packages/uipath-pydantic-ai/tests/test_chat.py create mode 100644 packages/uipath-pydantic-ai/tests/test_cli_new.py create mode 100644 packages/uipath-pydantic-ai/tests/test_factory_runtime.py create mode 100644 packages/uipath-pydantic-ai/tests/test_loader.py diff --git a/packages/uipath-agent-framework/tests/test_chat_clients.py b/packages/uipath-agent-framework/tests/test_chat_clients.py new file mode 100644 index 00000000..77579265 --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_chat_clients.py @@ -0,0 +1,119 @@ +"""Tests for UiPath gateway chat client transports and helpers.""" + +import importlib.util +import json +from unittest.mock import patch + +import httpx +import pytest + +from uipath_agent_framework.chat.anthropic import ( + _AsyncUrlRewriteTransport as AnthropicTransport, +) +from uipath_agent_framework.chat.anthropic import _check_anthropic_dependency +from uipath_agent_framework.chat.openai import ( + UiPathOpenAIChatClient, +) +from uipath_agent_framework.chat.openai import ( + _AsyncUrlRewriteTransport as OpenAITransport, +) + +_real_find_spec = importlib.util.find_spec + +GATEWAY = "https://cloud.uipath.com/org/tenant/llmgateway/openai/chat/completions" + + +async def _capture(transport, request): + """Invoke handle_async_request with the parent send mocked out.""" + captured = {} + + async def fake_super(self, req): + captured["request"] = req + return httpx.Response(200) + + with patch.object(httpx.AsyncHTTPTransport, "handle_async_request", fake_super): + await transport.handle_async_request(request) + return captured["request"] + + +async def test_openai_transport_rewrites_chat_completions_url(): + transport = OpenAITransport(GATEWAY) + request = httpx.Request( + "POST", + "https://api.openai.com/v1/chat/completions", + content=b'{"model":"gpt-4"}', + ) + out = await _capture(transport, request) + assert str(out.url) == GATEWAY + assert out.headers["host"] == "cloud.uipath.com" + + +async def test_openai_transport_sets_streaming_header_when_streaming(): + transport = OpenAITransport(GATEWAY) + request = httpx.Request( + "POST", + "https://api.openai.com/v1/chat/completions", + content=b'{"stream": true}', + ) + out = await _capture(transport, request) + assert out.headers["X-UiPath-Streaming-Enabled"] == "true" + + +async def test_openai_transport_passes_through_other_urls(): + transport = OpenAITransport(GATEWAY) + request = httpx.Request("GET", "https://api.openai.com/v1/models") + out = await _capture(transport, request) + assert str(out.url) == "https://api.openai.com/v1/models" + + +async def test_anthropic_transport_converts_body_to_bedrock_invoke_format(): + transport = AnthropicTransport(GATEWAY) + request = httpx.Request( + "POST", + "https://api.anthropic.com/v1/messages", + content=json.dumps({"model": "claude", "stream": True}).encode(), + ) + out = await _capture(transport, request) + body = json.loads(out.content) + assert "model" not in body + assert body["anthropic_version"] == "bedrock-2023-05-31" + assert out.headers["X-UiPath-Streaming-Enabled"] == "true" + + +def test_check_anthropic_dependency_raises_when_missing(monkeypatch): + # Force the "not installed" path so the check is deterministic regardless + # of whether the anthropic extra happens to be present in the environment. + monkeypatch.setattr( + "importlib.util.find_spec", + lambda name: None if name == "anthropic" else _real_find_spec(name), + ) + with pytest.raises(ImportError, match="anthropic"): + _check_anthropic_dependency() + + +def test_openai_client_normalizes_plain_callable_tools(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + client = UiPathOpenAIChatClient(model="gpt-4.1-mini") + + def my_tool(x: str) -> str: + """Echo a value.""" + return x + + captured = {} + + def fake_base(self, tools): + captured["tools"] = tools + return {} + + with patch.object( + UiPathOpenAIChatClient.__bases__[0], + "_prepare_tools_for_openai", + fake_base, + ): + client._prepare_tools_for_openai([my_tool]) + + # The plain function was wrapped as a FunctionTool before delegation. + from agent_framework import FunctionTool + + assert isinstance(captured["tools"][0], FunctionTool) diff --git a/packages/uipath-agent-framework/tests/test_chat_common.py b/packages/uipath-agent-framework/tests/test_chat_common.py new file mode 100644 index 00000000..a889bc2b --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_chat_common.py @@ -0,0 +1,57 @@ +"""Tests for chat gateway shared utilities.""" + +import pytest + +from uipath_agent_framework.chat._common import ( + build_gateway_url, + get_uipath_config, + get_uipath_headers, +) + + +def test_get_uipath_config_returns_url_and_token(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok-123") + assert get_uipath_config() == ("https://cloud.uipath.com/org/tenant", "tok-123") + + +def test_get_uipath_config_missing_url_raises(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL"): + get_uipath_config() + + +def test_get_uipath_config_missing_token_raises(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + with pytest.raises(ValueError, match="UIPATH_ACCESS_TOKEN"): + get_uipath_config() + + +def test_get_uipath_headers_includes_job_and_process_keys(monkeypatch): + monkeypatch.setenv("UIPATH_JOB_KEY", "job-1") + monkeypatch.setenv("UIPATH_PROCESS_KEY", "proc-1") + headers = get_uipath_headers("tok-abc") + assert headers["Authorization"] == "Bearer tok-abc" + assert headers["X-UiPath-JobKey"] == "job-1" + assert headers["X-UiPath-ProcessKey"] == "proc-1" + + +def test_get_uipath_headers_omits_optional_keys_when_unset(monkeypatch): + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_KEY", raising=False) + headers = get_uipath_headers("tok-abc") + assert "X-UiPath-JobKey" not in headers + assert "X-UiPath-ProcessKey" not in headers + + +def test_build_gateway_url_uses_explicit_url_and_strips_trailing_slash(): + url = build_gateway_url("openai", "gpt-4.1-mini", "https://cloud.uipath.com/org/") + assert url.startswith("https://cloud.uipath.com/org/") + assert "//" not in url[len("https://") :] + + +def test_build_gateway_url_missing_url_raises(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL"): + build_gateway_url("openai", "gpt-4.1-mini") diff --git a/packages/uipath-agent-framework/tests/test_chat_module.py b/packages/uipath-agent-framework/tests/test_chat_module.py new file mode 100644 index 00000000..0bb7c492 --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_chat_module.py @@ -0,0 +1,40 @@ +"""Tests for chat package lazy exports and the requires_approval decorator.""" + +import pytest +from agent_framework import FunctionTool + +from uipath_agent_framework import chat +from uipath_agent_framework.chat.tools import requires_approval + + +def test_requires_approval_bare_decorator_sets_always_require(): + @requires_approval + def transfer(amount: float) -> str: + """Move money.""" + return str(amount) + + assert isinstance(transfer, FunctionTool) + assert transfer.approval_mode == "always_require" + + +def test_requires_approval_called_form_sets_always_require(): + @requires_approval() + def transfer(amount: float) -> str: + """Move money.""" + return str(amount) + + assert isinstance(transfer, FunctionTool) + assert transfer.approval_mode == "always_require" + + +def test_chat_getattr_lazily_resolves_openai_client(): + from uipath_agent_framework.chat.openai import UiPathOpenAIChatClient + + name = "UiPathOpenAIChatClient" + assert getattr(chat, name) is UiPathOpenAIChatClient + + +def test_chat_getattr_unknown_name_raises_attribute_error(): + name = "DoesNotExist" + with pytest.raises(AttributeError): + getattr(chat, name) diff --git a/packages/uipath-agent-framework/tests/test_cli_new.py b/packages/uipath-agent-framework/tests/test_cli_new.py new file mode 100644 index 00000000..7ed0133a --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_cli_new.py @@ -0,0 +1,37 @@ +"""Tests for the `uipath new` Agent Framework scaffolding middleware.""" + +from uipath_agent_framework._cli import cli_new +from uipath_agent_framework._cli.cli_new import ( + agent_framework_new_middleware, + generate_pyproject, +) + + +def test_generate_pyproject_writes_project_name(tmp_path): + generate_pyproject(str(tmp_path), "my-agent") + content = (tmp_path / "pyproject.toml").read_text() + assert 'name = "my-agent"' in content + assert "uipath-agent-framework" in content + + +def test_middleware_scaffolds_project_files(tmp_path, monkeypatch): + monkeypatch.setattr("os.getcwd", lambda: str(tmp_path)) + result = agent_framework_new_middleware("demo") + + assert result.should_continue is False + assert (tmp_path / "main.py").exists() + assert (tmp_path / "agent_framework.json").exists() + assert (tmp_path / "pyproject.toml").exists() + + +def test_middleware_reports_error_with_stacktrace_on_failure(monkeypatch): + def boom(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(cli_new, "generate_pyproject", boom) + # console.error exits via the click context, which isn't active in tests. + monkeypatch.setattr(cli_new.console, "error", lambda *a, **k: None) + result = agent_framework_new_middleware("demo") + + assert result.should_continue is False + assert result.should_include_stacktrace is True diff --git a/packages/uipath-agent-framework/tests/test_loader.py b/packages/uipath-agent-framework/tests/test_loader.py new file mode 100644 index 00000000..aaeec8ba --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_loader.py @@ -0,0 +1,108 @@ +"""Tests for AgentFrameworkAgentLoader.""" + +import pytest + +from uipath_agent_framework.runtime.errors import ( + UiPathAgentFrameworkRuntimeError, +) +from uipath_agent_framework.runtime.loader import AgentFrameworkAgentLoader + + +def test_from_path_string_parses_file_and_variable(): + loader = AgentFrameworkAgentLoader.from_path_string("bot", "main.py:agent") + assert loader.file_path == "main.py" + assert loader.variable_name == "agent" + + +def test_from_path_string_without_colon_raises_config_invalid(): + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + AgentFrameworkAgentLoader.from_path_string("bot", "main.py") + assert exc.value.error_info.code == "Agent-Framework.CONFIG_INVALID" + + +@pytest.mark.asyncio +async def test_load_rejects_path_outside_cwd(): + loader = AgentFrameworkAgentLoader("bot", "/etc/passwd", "agent") + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code == "Agent-Framework.AGENT_VALUE_ERROR" + + +@pytest.mark.asyncio +async def test_load_missing_file_raises_not_found(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + loader = AgentFrameworkAgentLoader("bot", "missing.py", "agent") + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code == "Agent-Framework.AGENT_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_load_missing_variable_raises_not_found(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "mod_a.py").write_text("something = 1\n") + loader = AgentFrameworkAgentLoader("bot", "mod_a.py", "agent") + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code == "Agent-Framework.AGENT_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_load_rejects_non_workflow_agent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "mod_b.py").write_text("agent = object()\n") + loader = AgentFrameworkAgentLoader("bot", "mod_b.py", "agent") + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code == "Agent-Framework.AGENT_TYPE_ERROR" + + +@pytest.mark.asyncio +async def test_load_wraps_module_execution_error(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "mod_c.py").write_text("raise RuntimeError('boom')\n") + loader = AgentFrameworkAgentLoader("bot", "mod_c.py", "agent") + with pytest.raises(UiPathAgentFrameworkRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code == "Agent-Framework.AGENT_LOAD_ERROR" + + +@pytest.mark.asyncio +async def test_resolve_agent_calls_sync_factory(): + loader = AgentFrameworkAgentLoader("bot", "main.py", "agent") + sentinel = object() + resolved = await loader._resolve_agent(lambda: sentinel) + assert resolved is sentinel + + +@pytest.mark.asyncio +async def test_resolve_agent_awaits_async_factory(): + loader = AgentFrameworkAgentLoader("bot", "main.py", "agent") + sentinel = object() + + async def factory(): + return sentinel + + resolved = await loader._resolve_agent(factory) + assert resolved is sentinel + + +@pytest.mark.asyncio +async def test_resolve_agent_enters_async_context_manager_and_cleanup(): + loader = AgentFrameworkAgentLoader("bot", "main.py", "agent") + yielded = object() + exited = [] + + class _CM: + async def __aenter__(self): + return yielded + + async def __aexit__(self, *args): + exited.append(args) + + resolved = await loader._resolve_agent(_CM()) + assert resolved is yielded + + await loader.cleanup() + assert exited == [(None, None, None)] + assert loader._context_manager is None diff --git a/packages/uipath-agent-framework/tests/test_messages.py b/packages/uipath-agent-framework/tests/test_messages.py new file mode 100644 index 00000000..7a5bf035 --- /dev/null +++ b/packages/uipath-agent-framework/tests/test_messages.py @@ -0,0 +1,119 @@ +"""Tests for AgentFrameworkChatMessagesMapper inbound/outbound mapping.""" + +from agent_framework import Content +from uipath.core.chat import ( + UiPathConversationContentPart, + UiPathConversationMessage, + UiPathInlineValue, +) + +from uipath_agent_framework.runtime.messages import AgentFrameworkChatMessagesMapper + + +def _uipath_msg(role: str, text: str) -> UiPathConversationMessage: + return UiPathConversationMessage( + message_id=f"m-{role}", + role=role, + content_parts=[ + UiPathConversationContentPart( + content_part_id="c1", + mime_type="text/plain", + data=UiPathInlineValue(inline=text), + ) + ], + ) + + +def test_map_string_input_returned_as_is(): + mapper = AgentFrameworkChatMessagesMapper() + assert mapper.map_messages_to_input("hello world") == "hello world" + + +def test_map_uipath_messages_extracts_last_user_text(): + mapper = AgentFrameworkChatMessagesMapper() + messages = [_uipath_msg("user", "first"), _uipath_msg("user", "second")] + assert mapper.map_messages_to_input(messages) == "second" + + +def test_map_dict_messages_parsed_as_uipath(): + mapper = AgentFrameworkChatMessagesMapper() + messages = [_uipath_msg("user", "from dict").model_dump()] + assert mapper.map_messages_to_input(messages) == "from dict" + + +def test_map_raw_dicts_fallback_uses_content_field(): + mapper = AgentFrameworkChatMessagesMapper() + # Dicts that are not valid UiPath conversation messages fall back to raw. + messages = [{"content": "raw one"}, {"content": "raw two"}] + assert mapper.map_messages_to_input(messages) == "raw one\nraw two" + + +def test_map_list_of_strings_joined_with_newline(): + mapper = AgentFrameworkChatMessagesMapper() + assert mapper.map_messages_to_input(["a", "b"]) == "a\nb" + + +def test_map_empty_input_returns_empty_string(): + mapper = AgentFrameworkChatMessagesMapper() + assert mapper.map_messages_to_input([]) == "" + + +def test_extract_falls_back_to_last_message_when_no_user_role(): + mapper = AgentFrameworkChatMessagesMapper() + messages = [_uipath_msg("assistant", "only assistant text")] + assert mapper.map_messages_to_input(messages) == "only assistant text" + + +def test_text_content_emits_message_start_then_chunk(): + mapper = AgentFrameworkChatMessagesMapper() + events = mapper.map_streaming_content(Content(type="text", text="hi")) + # First a message start (with content_part start), then the chunk. + assert events[0].start is not None + chunk_part = events[1].content_part + assert chunk_part is not None and chunk_part.chunk is not None + assert chunk_part.chunk.data == "hi" + # Second text chunk reuses the same open message, no new start. + events2 = mapper.map_streaming_content(Content(type="text", text="!")) + assert events2[0].start is None + chunk_part2 = events2[0].content_part + assert chunk_part2 is not None and chunk_part2.chunk is not None + assert chunk_part2.chunk.data == "!" + + +def test_function_call_then_result_pairs_tool_events_and_closes_message(): + mapper = AgentFrameworkChatMessagesMapper() + start_events = mapper.map_streaming_content( + Content(type="function_call", name="lookup", call_id="c1", arguments={"q": "x"}) + ) + tool_start = [e for e in start_events if e.tool_call and e.tool_call.start][0] + assert tool_start.tool_call is not None and tool_start.tool_call.start is not None + assert tool_start.tool_call.start.tool_name == "lookup" + assert tool_start.tool_call.start.input == {"q": "x"} + + result_events = mapper.map_streaming_content( + Content(type="function_result", call_id="c1", result="done") + ) + tool_end = [e for e in result_events if e.tool_call and e.tool_call.end][0] + assert tool_end.tool_call is not None and tool_end.tool_call.end is not None + assert tool_end.tool_call.end.output == "done" + # Message closed once all pending tool calls resolved. + assert any(e.end is not None for e in result_events) + + +def test_function_call_partial_chunk_without_name_is_skipped(): + mapper = AgentFrameworkChatMessagesMapper() + events = mapper.map_streaming_content( + Content(type="function_call", name="", call_id="c1", arguments={}) + ) + assert events == [] + + +def test_close_message_emits_end_for_unresolved_tool_call(): + mapper = AgentFrameworkChatMessagesMapper() + mapper.map_streaming_content( + Content(type="function_call", name="pending", call_id="c9", arguments={}) + ) + events = mapper.close_message() + tool_end = [e for e in events if e.tool_call and e.tool_call.end][0] + assert tool_end.tool_call is not None and tool_end.tool_call.end is not None + assert tool_end.tool_call.end.output == {} diff --git a/packages/uipath-google-adk/tests/test_cli_new.py b/packages/uipath-google-adk/tests/test_cli_new.py new file mode 100644 index 00000000..3a70de4b --- /dev/null +++ b/packages/uipath-google-adk/tests/test_cli_new.py @@ -0,0 +1,32 @@ +"""Tests for _cli/cli_new.py — project scaffolding middleware.""" + +from uipath_google_adk._cli.cli_new import ( + generate_pyproject, + generate_script, + google_adk_new_middleware, +) + + +class TestGeneratePyproject: + def test_writes_pyproject_with_project_name(self, tmp_path): + generate_pyproject(str(tmp_path), "my-agent") + content = (tmp_path / "pyproject.toml").read_text() + assert 'name = "my-agent"' in content + assert "uipath-google-adk" in content + + +class TestGenerateScript: + def test_copies_templates(self, tmp_path): + generate_script(str(tmp_path)) + assert (tmp_path / "main.py").exists() + assert (tmp_path / "google_adk.json").exists() + + +class TestMiddleware: + def test_creates_project_and_stops_pipeline(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = google_adk_new_middleware("demo") + assert result.should_continue is False + assert (tmp_path / "main.py").exists() + assert (tmp_path / "google_adk.json").exists() + assert (tmp_path / "pyproject.toml").exists() diff --git a/packages/uipath-google-adk/tests/test_common.py b/packages/uipath-google-adk/tests/test_common.py new file mode 100644 index 00000000..c99f98db --- /dev/null +++ b/packages/uipath-google-adk/tests/test_common.py @@ -0,0 +1,61 @@ +"""Tests for chat/_common.py UiPath configuration helpers.""" + +import pytest + +from uipath_google_adk.chat._common import ( + build_gateway_url, + get_uipath_config, + get_uipath_headers, +) + + +class TestGetUiPathConfig: + def test_returns_url_and_token(self, monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "secret-token") + assert get_uipath_config() == ( + "https://cloud.uipath.com/org/tenant", + "secret-token", + ) + + def test_missing_url_raises(self, monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "t") + with pytest.raises(ValueError, match="UIPATH_URL"): + get_uipath_config() + + def test_missing_token_raises(self, monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://x") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + with pytest.raises(ValueError, match="UIPATH_ACCESS_TOKEN"): + get_uipath_config() + + +class TestGetUiPathHeaders: + def test_authorization_only_without_optional_env(self, monkeypatch): + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_KEY", raising=False) + headers = get_uipath_headers("abc") + assert headers == {"Authorization": "Bearer abc"} + + def test_includes_job_and_process_keys_when_set(self, monkeypatch): + monkeypatch.setenv("UIPATH_JOB_KEY", "job-1") + monkeypatch.setenv("UIPATH_PROCESS_KEY", "proc-1") + headers = get_uipath_headers("abc") + assert headers["X-UiPath-JobKey"] == "job-1" + assert headers["X-UiPath-ProcessKey"] == "proc-1" + + +class TestBuildGatewayUrl: + def test_builds_url_with_explicit_uipath_url(self): + url = build_gateway_url("openai", "gpt-4.1", "https://cloud.uipath.com/org/") + # vendor/model are substituted into the endpoint template, trailing slash stripped + assert url.startswith("https://cloud.uipath.com/org/") + assert "openai" in url + assert "gpt-4.1" in url + assert "//org//" not in url + + def test_missing_url_raises(self, monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL"): + build_gateway_url("openai", "gpt-4.1") diff --git a/packages/uipath-google-adk/tests/test_factory.py b/packages/uipath-google-adk/tests/test_factory.py new file mode 100644 index 00000000..5212571e --- /dev/null +++ b/packages/uipath-google-adk/tests/test_factory.py @@ -0,0 +1,132 @@ +"""Tests for runtime/factory.py — UiPathGoogleADKRuntimeFactory.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from google.adk.agents import LlmAgent +from google.adk.sessions.session import Session + +from uipath_google_adk.runtime import factory as factory_mod +from uipath_google_adk.runtime.errors import UiPathGoogleADKRuntimeError +from uipath_google_adk.runtime.factory import UiPathGoogleADKRuntimeFactory +from uipath_google_adk.runtime.runtime import UiPathGoogleADKRuntime + + +@pytest.fixture(autouse=True) +def _no_instrumentation(monkeypatch): + """Avoid real OpenTelemetry instrumentation side effects.""" + monkeypatch.setattr(factory_mod, "GoogleADKInstrumentor", lambda: MagicMock()) + + +def _make_context(tmp_path, resume=False, job_id=None, keep=False): + state_file = tmp_path / "state.db" + return SimpleNamespace( + resolved_state_file_path=str(state_file), + resume=resume, + job_id=job_id, + keep_state_file=keep, + ) + + +def _write_config(tmp_path, agents): + (tmp_path / "google_adk.json").write_text(json.dumps({"agents": agents})) + + +class TestDiscoverEntrypoints: + def test_returns_empty_when_no_config(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + assert factory.discover_entrypoints() == [] + + def test_returns_agent_names(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, {"agent": "main.py:agent"}) + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + assert factory.discover_entrypoints() == ["agent"] + + +class TestGetConnectionString: + def test_deletes_stale_state_when_not_resuming(self, tmp_path): + ctx = _make_context(tmp_path, resume=False, job_id=None, keep=False) + stale = tmp_path / "state.db" + stale.write_text("old") + factory = UiPathGoogleADKRuntimeFactory(ctx) + path = factory._get_connection_string() + assert path == str(stale) + assert not stale.exists() + + def test_keeps_state_when_resuming(self, tmp_path): + ctx = _make_context(tmp_path, resume=True) + keep = tmp_path / "state.db" + keep.write_text("keep") + factory = UiPathGoogleADKRuntimeFactory(ctx) + factory._get_connection_string() + assert keep.exists() + + +class TestLoadAgent: + @pytest.mark.asyncio + async def test_missing_config_raises(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + with pytest.raises(UiPathGoogleADKRuntimeError, match="configuration"): + await factory._load_agent("agent") + + @pytest.mark.asyncio + async def test_unknown_entrypoint_raises(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, {"agent": "main.py:agent"}) + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + with pytest.raises(UiPathGoogleADKRuntimeError, match="not found"): + await factory._load_agent("missing") + + +class TestResolveAgentCaches: + @pytest.mark.asyncio + async def test_second_resolve_uses_cache(self, tmp_path, monkeypatch): + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + agent = LlmAgent(name="asst", model="gemini-2.0-flash") + load_agent = AsyncMock(return_value=agent) + monkeypatch.setattr(factory, "_load_agent", load_agent) + first = await factory._resolve_agent("agent") + second = await factory._resolve_agent("agent") + assert first is second + load_agent.assert_awaited_once() + + +class TestNewRuntime: + @pytest.mark.asyncio + async def test_creates_runtime_with_session(self, tmp_path, monkeypatch): + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + agent = LlmAgent(name="asst", model="gemini-2.0-flash") + monkeypatch.setattr(factory, "_resolve_agent", AsyncMock(return_value=agent)) + + session = Session(id="rt-1", app_name="uipath", user_id="uipath-user", state={}) + session_service = MagicMock() + session_service.get_session = AsyncMock(return_value=None) + session_service.create_session = AsyncMock(return_value=session) + monkeypatch.setattr( + factory, "_get_session_service", AsyncMock(return_value=session_service) + ) + + runtime = await factory.new_runtime("agent", "rt-1") + assert isinstance(runtime, UiPathGoogleADKRuntime) + assert runtime.runtime_id == "rt-1" + assert runtime.agent is agent + session_service.create_session.assert_awaited_once() + + +class TestDispose: + @pytest.mark.asyncio + async def test_dispose_cleans_loaders_and_cache(self, tmp_path): + factory = UiPathGoogleADKRuntimeFactory(_make_context(tmp_path)) + loader = MagicMock() + loader.cleanup = AsyncMock() + factory._agent_loaders = {"agent": loader} + factory._agent_cache = {"agent": MagicMock()} + await factory.dispose() + loader.cleanup.assert_awaited_once() + assert factory._agent_cache == {} + assert factory._agent_loaders == {} diff --git a/packages/uipath-google-adk/tests/test_gemini.py b/packages/uipath-google-adk/tests/test_gemini.py new file mode 100644 index 00000000..ca16e455 --- /dev/null +++ b/packages/uipath-google-adk/tests/test_gemini.py @@ -0,0 +1,46 @@ +"""Tests for chat/gemini.py — URL rewriting transports and api_client.""" + +import httpx +from google.genai import Client + +from uipath_google_adk.chat.gemini import ( + UiPathGemini, + _rewrite_request_for_gateway, +) + + +class TestRewriteRequestForGateway: + def test_generate_content_url_rewritten_to_gateway(self): + gateway = "https://cloud.uipath.com/gw/generateContent" + original = httpx.Request( + "POST", + "https://generativelanguage.googleapis.com/v1/models/x:generateContent", + content=b"{}", + ) + rewritten = _rewrite_request_for_gateway(original, gateway) + assert rewritten.url.host == "cloud.uipath.com" + assert str(rewritten.url).startswith(gateway) + + def test_streaming_sets_streaming_header_and_preserves_params(self): + gateway = "https://cloud.uipath.com/gw/stream" + original = httpx.Request( + "POST", + "https://generativelanguage.googleapis.com/v1/x:streamGenerateContent?alt=sse", + content=b"{}", + ) + rewritten = _rewrite_request_for_gateway(original, gateway) + assert rewritten.headers["X-UiPath-Streaming-Enabled"] == "true" + assert "alt=sse" in str(rewritten.url) + + def test_unrelated_request_passed_through_unchanged(self): + original = httpx.Request("GET", "https://example.com/other") + assert _rewrite_request_for_gateway(original, "https://gw") is original + + +class TestApiClient: + def test_builds_gateway_client(self, monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + gemini = UiPathGemini(model="gemini-2.5-flash") + client = gemini.api_client + assert isinstance(client, Client) diff --git a/packages/uipath-google-adk/tests/test_lazy_imports.py b/packages/uipath-google-adk/tests/test_lazy_imports.py new file mode 100644 index 00000000..fcb87697 --- /dev/null +++ b/packages/uipath-google-adk/tests/test_lazy_imports.py @@ -0,0 +1,43 @@ +"""Tests for lazy __getattr__ exports and middleware registration.""" + +import pytest + +import uipath_google_adk +from uipath_google_adk import chat +from uipath_google_adk.chat import UiPathOpenAI + + +class TestChatLazyImports: + def test_getattr_resolves_openai(self): + from uipath_google_adk.chat.openai import UiPathOpenAI as Direct + + assert chat.UiPathOpenAI is Direct + + def test_unknown_attribute_raises(self): + with pytest.raises(AttributeError): + _ = chat.NotARealThing + + +class TestPackageLazyImports: + def test_top_level_reexports_from_chat(self): + assert uipath_google_adk.UiPathOpenAI is UiPathOpenAI + + def test_unknown_attribute_raises(self): + with pytest.raises(AttributeError): + _ = uipath_google_adk.Nonexistent + + +class TestMiddlewareRegistration: + def test_register_middleware_registers_new_hook(self, monkeypatch): + from uipath._cli.middlewares import Middlewares + + from uipath_google_adk.middlewares import register_middleware + + captured = {} + monkeypatch.setattr( + Middlewares, + "register", + classmethod(lambda cls, name, fn: captured.update({name: fn})), + ) + register_middleware() + assert "new" in captured diff --git a/packages/uipath-google-adk/tests/test_messages.py b/packages/uipath-google-adk/tests/test_messages.py new file mode 100644 index 00000000..ccd4e109 --- /dev/null +++ b/packages/uipath-google-adk/tests/test_messages.py @@ -0,0 +1,181 @@ +"""Tests for runtime/messages.py — GoogleADKChatMessagesMapper.""" + +from google.adk.events.event import Event +from google.genai import types +from uipath.core.chat import ( + UiPathConversationContentPart, + UiPathConversationMessage, + UiPathInlineValue, +) +from uipath.core.chat.tool import ( + UiPathConversationToolCall, + UiPathConversationToolCallResult, +) + +from uipath_google_adk.runtime.messages import GoogleADKChatMessagesMapper + + +def _text_part(text: str) -> UiPathConversationContentPart: + return UiPathConversationContentPart( + mime_type="text/plain", data=UiPathInlineValue(inline=text) + ) + + +def _user_msg(text: str) -> UiPathConversationMessage: + return UiPathConversationMessage(role="user", content_parts=[_text_part(text)]) + + +class TestMapMessages: + def test_passthrough_content_objects(self): + mapper = GoogleADKChatMessagesMapper() + content = types.Content(role="user", parts=[types.Part(text="hi")]) + assert mapper.map_messages([content]) == [content] + + def test_empty_returns_empty(self): + assert GoogleADKChatMessagesMapper().map_messages([]) == [] + + def test_uipath_conversation_messages(self): + mapper = GoogleADKChatMessagesMapper() + contents = mapper.map_messages([_user_msg("hello there")]) + assert len(contents) == 1 + assert contents[0].role == "user" + parts = contents[0].parts + assert parts is not None + assert parts[0].text == "hello there" + + def test_list_of_dicts_parsed(self): + mapper = GoogleADKChatMessagesMapper() + raw = _user_msg("from dict").model_dump(by_alias=True) + contents = mapper.map_messages([raw]) + parts = contents[0].parts + assert parts is not None + assert parts[0].text == "from dict" + + def test_fallback_raw_text(self): + mapper = GoogleADKChatMessagesMapper() + contents = mapper.map_messages(["plain string"]) + assert contents[0].role == "user" + parts = contents[0].parts + assert parts is not None + assert parts[0].text == "plain string" + + +class TestMapMessagesInternalAssistant: + def test_assistant_tool_call_and_response(self): + mapper = GoogleADKChatMessagesMapper() + tc = UiPathConversationToolCall( + name="lookup", + input={"q": "x"}, + tool_call_id="call-1", + created_at="t", + updated_at="t", + result=UiPathConversationToolCallResult(output={"answer": 1}), + ) + assistant = UiPathConversationMessage( + role="assistant", + content_parts=[_text_part("using tool")], + tool_calls=[tc], + ) + contents = mapper.map_messages([assistant]) + # model content (text + function_call) then a user content with the response + model_content = contents[0] + assert model_content.role == "model" + model_parts = model_content.parts + assert model_parts is not None + assert model_parts[0].text == "using tool" + function_call = model_parts[1].function_call + assert function_call is not None + assert function_call.name == "lookup" + response_parts = contents[1].parts + assert response_parts is not None + function_response = response_parts[0].function_response + assert function_response is not None + assert function_response.response == {"answer": 1} + + +class TestNormalizeToolOutput: + def test_none_becomes_empty_dict(self): + assert GoogleADKChatMessagesMapper._normalize_tool_output(None) == {} + + def test_json_string_parsed_to_dict(self): + out = GoogleADKChatMessagesMapper._normalize_tool_output('{"a": 1}') + assert out == {"a": 1} + + def test_plain_string_wrapped_in_result(self): + out = GoogleADKChatMessagesMapper._normalize_tool_output("hi") + assert out == {"result": "hi"} + + def test_non_string_non_dict_wrapped(self): + out = GoogleADKChatMessagesMapper._normalize_tool_output(42) + assert out == {"result": "42"} + + +class TestMapEvent: + def test_partial_text_starts_message_and_emits_chunk(self): + mapper = GoogleADKChatMessagesMapper() + event = Event( + author="asst", + content=types.Content(role="model", parts=[types.Part(text="Hel")]), + partial=True, + ) + events = mapper.map_event(event) + # first the message start, then the content chunk + assert events[0].start is not None + content_part = events[1].content_part + assert content_part is not None and content_part.chunk is not None + assert content_part.chunk.data == "Hel" + + def test_user_author_ignored(self): + mapper = GoogleADKChatMessagesMapper() + event = Event( + author="user", + content=types.Content(role="user", parts=[types.Part(text="hi")]), + ) + assert mapper.map_event(event) == [] + + def test_function_call_emits_tool_call_start(self): + mapper = GoogleADKChatMessagesMapper() + part = types.Part.from_function_call(name="search", args={"q": "x"}) + assert part.function_call is not None + part.function_call.id = "fc-1" + event = Event( + author="asst", + content=types.Content(role="model", parts=[part]), + ) + events = mapper.map_event(event) + tool_events = [e for e in events if e.tool_call is not None] + tool_call = tool_events[0].tool_call + assert tool_call is not None and tool_call.start is not None + assert tool_call.start.tool_name == "search" + assert "fc-1" in mapper._pending_tool_calls + + def test_final_response_closes_message(self): + mapper = GoogleADKChatMessagesMapper() + # start a message via a partial chunk first + mapper.map_event( + Event( + author="asst", + content=types.Content(role="model", parts=[types.Part(text="Hi")]), + partial=True, + ) + ) + final = Event( + author="asst", + content=types.Content(role="model", parts=[types.Part(text="Hi done")]), + ) + events = mapper.map_event(final) + assert any(e.end is not None for e in events) + assert mapper._message_started is False + + +class TestCloseMessage: + def test_close_open_message(self): + mapper = GoogleADKChatMessagesMapper() + mapper._message_started = True + mapper._current_message_id = "m1" + events = mapper.close_message() + assert events[0].end is not None + assert mapper._message_started is False + + def test_close_noop_when_no_open_message(self): + assert GoogleADKChatMessagesMapper().close_message() == [] diff --git a/packages/uipath-google-adk/tests/test_openai.py b/packages/uipath-google-adk/tests/test_openai.py new file mode 100644 index 00000000..6619628a --- /dev/null +++ b/packages/uipath-google-adk/tests/test_openai.py @@ -0,0 +1,382 @@ +"""Tests for chat/openai.py — request/response conversion and gateway calls.""" + +import json +from typing import Any + +import httpx +import pytest +from google.adk.models.llm_request import LlmRequest +from google.adk.models.llm_response import LlmResponse +from google.genai import types +from pydantic import BaseModel + +from uipath_google_adk.chat.openai import ( + UiPathOpenAI, + _content_to_messages, + _function_declaration_to_tool, + _message_to_llm_response, + _parse_complete_response, + _parts_to_content, + _safe_json, + _schema_to_dict, + _to_openai_role, + _to_response_format, +) + + +class TestSmallHelpers: + def test_to_openai_role_maps_model_to_assistant(self): + assert _to_openai_role("model") == "assistant" + assert _to_openai_role("assistant") == "assistant" + assert _to_openai_role("user") == "user" + assert _to_openai_role(None) == "user" + + def test_safe_json_passes_through_string_and_serializes_dict(self): + assert _safe_json("already") == "already" + assert _safe_json({"a": 1}) == '{"a": 1}' + + def test_safe_json_falls_back_to_str_on_unserializable(self): + obj = object() + assert _safe_json(obj) == str(obj) + + +class TestSchemaToDict: + def test_lowercases_type_and_recurses_into_items_and_properties(self): + schema = types.Schema( + type=types.Type.OBJECT, + properties={ + "tags": types.Schema( + type=types.Type.ARRAY, + items=types.Schema(type=types.Type.STRING), + ) + }, + ) + result = _schema_to_dict(schema) + assert result["type"] == "object" + assert result["properties"]["tags"]["type"] == "array" + assert result["properties"]["tags"]["items"]["type"] == "string" + + +class TestContentToMessages: + def test_user_text_content(self): + content = types.Content(role="user", parts=[types.Part.from_text(text="hi")]) + msgs = _content_to_messages(content) + assert msgs == [{"role": "user", "content": "hi"}] + + def test_assistant_with_tool_call(self): + content = types.Content( + role="model", + parts=[ + types.Part.from_function_call(name="lookup", args={"q": "x"}), + ], + ) + msgs = _content_to_messages(content) + assert msgs[0]["role"] == "assistant" + assert msgs[0]["tool_calls"][0]["function"]["name"] == "lookup" + assert json.loads(msgs[0]["tool_calls"][0]["function"]["arguments"]) == { + "q": "x" + } + + def test_function_response_becomes_tool_message(self): + part = types.Part.from_function_response(name="lookup", response={"result": 42}) + assert part.function_response is not None + part.function_response.id = "call-1" + content = types.Content(role="user", parts=[part]) + msgs = _content_to_messages(content) + assert msgs[0]["role"] == "tool" + assert msgs[0]["tool_call_id"] == "call-1" + assert json.loads(msgs[0]["content"]) == {"result": 42} + + +class TestPartsToContent: + def test_single_text_returns_string(self): + parts = [types.Part.from_text(text="solo")] + assert _parts_to_content(parts) == "solo" + + def test_inline_image_becomes_image_url_object(self): + part = types.Part( + inline_data=types.Blob(mime_type="image/png", data=b"\x89PNG") + ) + result = _parts_to_content([types.Part.from_text(text="see"), part]) + assert isinstance(result, list) + assert {"type": "text", "text": "see"} in result + image_obj = [o for o in result if o["type"] == "image_url"][0] + assert image_obj["image_url"]["url"].startswith("data:image/png;base64,") + + +class TestFunctionDeclarationToTool: + def test_converts_declaration_with_required_params(self): + decl = types.FunctionDeclaration( + name="search", + description="Search things", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={"q": types.Schema(type=types.Type.STRING)}, + required=["q"], + ), + ) + tool = _function_declaration_to_tool(decl) + assert tool["type"] == "function" + assert tool["function"]["name"] == "search" + assert tool["function"]["parameters"]["properties"]["q"]["type"] == "string" + assert tool["function"]["parameters"]["required"] == ["q"] + + +class TestToResponseFormat: + def test_pydantic_model_produces_strict_json_schema(self): + class Answer(BaseModel): + value: int + + fmt = _to_response_format(Answer) + assert fmt is not None + assert fmt["type"] == "json_schema" + assert fmt["json_schema"]["name"] == "Answer" + assert fmt["json_schema"]["strict"] is True + assert fmt["json_schema"]["schema"]["additionalProperties"] is False + + def test_unsupported_type_returns_none(self): + assert _to_response_format(123) is None + + +class TestMessageToLlmResponse: + def test_maps_reasoning_text_and_tool_calls(self): + message = { + "reasoning_content": "thinking...", + "content": "final answer", + "tool_calls": [ + { + "type": "function", + "id": "c1", + "function": {"name": "f", "arguments": '{"x": 1}'}, + } + ], + } + resp = _message_to_llm_response(message, model_version="gpt-x") + assert resp.content is not None and resp.content.parts is not None + parts = resp.content.parts + assert parts[0].thought is True and parts[0].text == "thinking..." + assert parts[1].text == "final answer" + assert parts[2].function_call is not None + assert parts[2].function_call.name == "f" + assert parts[2].function_call.id == "c1" + assert resp.model_version == "gpt-x" + + +class TestParseCompleteResponse: + def test_parses_message_finish_reason_and_usage(self): + data = { + "model": "gpt-4.1", + "choices": [{"message": {"content": "hello"}, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 5, + "total_tokens": 8, + }, + } + resp = _parse_complete_response(data) + assert resp.content is not None and resp.content.parts is not None + assert resp.content.parts[0].text == "hello" + assert resp.finish_reason == types.FinishReason.STOP + assert resp.usage_metadata is not None + assert resp.usage_metadata.total_token_count == 8 + + def test_no_choices_raises(self): + with pytest.raises(ValueError, match="No choices"): + _parse_complete_response({"choices": []}) + + +class TestBuildRequestBody: + def test_includes_system_tools_response_format_and_params(self): + llm = UiPathOpenAI(model="gpt-4.1") + + class Out(BaseModel): + answer: str + + config = types.GenerateContentConfig( + system_instruction="be brief", + temperature=0.5, + max_output_tokens=100, + tools=[ + types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name="t", + parameters=types.Schema(type=types.Type.OBJECT), + ) + ] + ) + ], + response_schema=Out, + ) + request = LlmRequest( + model="gpt-4.1", + contents=[ + types.Content(role="user", parts=[types.Part.from_text(text="hi")]) + ], + config=config, + ) + body = llm._build_request_body(request, "gpt-4.1", stream=False) + assert body["messages"][0] == {"role": "system", "content": "be brief"} + assert body["messages"][1]["content"] == "hi" + assert body["tools"][0]["function"]["name"] == "t" + assert body["response_format"]["type"] == "json_schema" + assert body["temperature"] == 0.5 + # max_output_tokens is remapped to max_completion_tokens + assert body["max_completion_tokens"] == 100 + assert "stream" not in body + + def test_stream_adds_stream_options(self): + llm = UiPathOpenAI(model="gpt-4.1") + request = LlmRequest(model="gpt-4.1", contents=[]) + body = llm._build_request_body(request, "gpt-4.1", stream=True) + assert body["stream"] is True + assert body["stream_options"] == {"include_usage": True} + + +class TestSupportedModels: + def test_patterns_include_gpt_and_o_series(self): + patterns = UiPathOpenAI.supported_models() + import re + + assert any(re.fullmatch(p, "gpt-4.1-2025-04-14") for p in patterns) + assert any(re.fullmatch(p, "o3-mini") for p in patterns) + + +class _FakeResponse: + def __init__(self, data): + self._data = data + + def raise_for_status(self): + pass + + def json(self): + return self._data + + +class _FakeAsyncClient: + def __init__(self, response, captured): + self._response = response + self._captured = captured + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, headers=None, json=None): + self._captured["url"] = url + self._captured["headers"] = headers + self._captured["body"] = json + return self._response + + +class TestGenerateContentAsync: + @pytest.mark.asyncio + async def test_non_streaming_posts_to_gateway_and_parses(self, monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + captured: dict[str, Any] = {} + response = _FakeResponse( + {"choices": [{"message": {"content": "world"}, "finish_reason": "stop"}]} + ) + + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: _FakeAsyncClient(response, captured), + ) + + llm = UiPathOpenAI(model="gpt-4.1") + request = LlmRequest( + model="gpt-4.1", + contents=[ + types.Content(role="user", parts=[types.Part.from_text(text="hello")]) + ], + ) + results = [r async for r in llm.generate_content_async(request, stream=False)] + assert len(results) == 1 + content = results[0].content + assert content is not None and content.parts is not None + assert content.parts[0].text == "world" + assert captured["headers"]["X-UiPath-LlmGateway-ApiFlavor"] == ( + "OpenAiChatCompletions" + ) + assert captured["headers"]["Authorization"] == "Bearer tok" + + +class _FakeStreamResponse: + def __init__(self, lines): + self._lines = lines + + def raise_for_status(self): + pass + + async def aiter_lines(self): + for line in self._lines: + yield line + + +class _FakeStreamCtx: + def __init__(self, response): + self._response = response + + async def __aenter__(self): + return self._response + + async def __aexit__(self, *exc): + return False + + +class _FakeStreamingClient: + def __init__(self, lines): + self._lines = lines + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def stream(self, method, url, headers=None, json=None): + return _FakeStreamCtx(_FakeStreamResponse(self._lines)) + + +class TestStreamRequest: + @pytest.mark.asyncio + async def test_streams_partial_text_and_aggregates_final(self, monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + + lines = [ + 'data: {"model": "gpt-4.1", "choices": [{"delta": {"content": "Hel"}}]}', + 'data: {"choices": [{"delta": {"content": "lo"}, "finish_reason": "stop"}]}', + 'data: {"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}}', + "data: [DONE]", + ] + monkeypatch.setattr( + httpx, + "AsyncClient", + lambda **kw: _FakeStreamingClient(lines), + ) + + llm = UiPathOpenAI(model="gpt-4.1") + request = LlmRequest( + model="gpt-4.1", + contents=[ + types.Content(role="user", parts=[types.Part.from_text(text="hi")]) + ], + ) + results = [r async for r in llm.generate_content_async(request, stream=True)] + + def _first_text(resp: LlmResponse) -> str: + assert resp.content is not None and resp.content.parts is not None + return resp.content.parts[0].text or "" + + # partial chunks (Hel, lo) plus an aggregated final response + partials = [r for r in results if r.partial] + assert "".join(_first_text(p) for p in partials) == "Hello" + final = [r for r in results if not r.partial][-1] + assert _first_text(final) == "Hello" + assert final.usage_metadata is not None + assert final.usage_metadata.total_token_count == 3 diff --git a/packages/uipath-google-adk/tests/test_runtime.py b/packages/uipath-google-adk/tests/test_runtime.py new file mode 100644 index 00000000..9d53857c --- /dev/null +++ b/packages/uipath-google-adk/tests/test_runtime.py @@ -0,0 +1,218 @@ +"""Tests for runtime/runtime.py — UiPathGoogleADKRuntime.""" + +import json +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest +from google.adk.agents import BaseAgent, LlmAgent +from google.adk.events.event import Event +from google.adk.sessions.session import Session +from google.genai import types +from pydantic import BaseModel +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.errors import UiPathErrorCode +from uipath.runtime.events import UiPathRuntimeStateEvent + +from uipath_google_adk.runtime.errors import ( + UiPathGoogleADKErrorCode, + UiPathGoogleADKRuntimeError, +) +from uipath_google_adk.runtime.runtime import UiPathGoogleADKRuntime + + +class OutModel(BaseModel): + answer: str + + +def _make_runtime( + agent: Optional[BaseAgent] = None, + session_state: Optional[dict[str, Any]] = None, +) -> UiPathGoogleADKRuntime: + agent = agent or LlmAgent(name="asst", model="gemini-2.0-flash") + session = Session( + id="sess-1", + app_name=UiPathGoogleADKRuntime.APP_NAME, + user_id=UiPathGoogleADKRuntime.USER_ID, + state=session_state or {}, + ) + runner = MagicMock() + session_service = MagicMock() + session_service.get_session = AsyncMock(return_value=session) + return UiPathGoogleADKRuntime( + agent=agent, + runner=runner, + session=session, + session_service=session_service, + runtime_id="rt-1", + entrypoint="asst", + ) + + +class TestPrepareUserMessage: + def test_no_input_yields_empty_text(self): + rt = _make_runtime() + msg = rt._prepare_user_message(None) + assert msg.parts is not None + assert msg.role == "user" and msg.parts[0].text == "" + + def test_string_messages(self): + rt = _make_runtime() + msg = rt._prepare_user_message({"messages": "hello"}) + assert msg.parts is not None + assert msg.parts[0].text == "hello" + + def test_typed_input_serialized_as_json(self): + rt = _make_runtime() + msg = rt._prepare_user_message({"query": "x", "limit": 5}) + assert msg.parts is not None + assert json.loads(msg.parts[0].text or "") == {"query": "x", "limit": 5} + + +class TestExtractOutput: + def test_output_key_from_session_state(self): + agent = LlmAgent(name="a", model="gemini-2.0-flash", output_key="result") + rt = _make_runtime(agent=agent, session_state={"result": {"answer": "42"}}) + assert rt._extract_output("ignored") == {"answer": "42"} + + def test_output_schema_parses_final_text(self): + agent = LlmAgent(name="a", model="gemini-2.0-flash", output_schema=OutModel) + rt = _make_runtime(agent=agent) + assert rt._extract_output('{"answer": "hi"}') == {"answer": "hi"} + + def test_falls_back_to_raw_text(self): + rt = _make_runtime() + assert rt._extract_output("plain") == "plain" + + +class TestCreateSuccessResult: + def test_dict_output_preserved(self): + rt = _make_runtime() + result = rt._create_success_result({"answer": "x"}) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"answer": "x"} + + def test_non_dict_output_wrapped_in_messages(self): + rt = _make_runtime() + result = rt._create_success_result("plain text") + assert isinstance(result.output, dict) + assert result.output["messages"][0]["role"] == "assistant" + + +class TestCreateRuntimeError: + def test_passthrough_existing_error(self): + rt = _make_runtime() + original = UiPathGoogleADKRuntimeError( + UiPathGoogleADKErrorCode.AGENT_LOAD_ERROR, "t", "d" + ) + assert rt._create_runtime_error(original) is original + + def test_json_decode_error_mapped(self): + rt = _make_runtime() + err = rt._create_runtime_error(json.JSONDecodeError("bad", "doc", 0)) + assert err.error_info.code.endswith(UiPathErrorCode.INPUT_INVALID_JSON.value) + + def test_timeout_error_mapped(self): + rt = _make_runtime() + err = rt._create_runtime_error(TimeoutError("slow")) + assert err.error_info.code.endswith( + UiPathGoogleADKErrorCode.TIMEOUT_ERROR.value + ) + + def test_generic_error_mapped(self): + rt = _make_runtime() + err = rt._create_runtime_error(RuntimeError("boom")) + assert err.error_info.code.endswith( + UiPathGoogleADKErrorCode.AGENT_EXECUTION_FAILURE.value + ) + + +class TestConvertEvent: + def test_user_author_returns_empty(self): + rt = _make_runtime() + event = Event(author="user") + assert rt._convert_event(event) == [] + + def test_real_function_call_emits_agent_and_tools_nodes(self): + rt = _make_runtime() + part = types.Part.from_function_call(name="search", args={"q": "x"}) + event = Event(author="asst", content=types.Content(role="model", parts=[part])) + events = rt._convert_event(event) + node_names = {e.node_name for e in events} + assert "asst" in node_names + assert "asst_tools" in node_names + + def test_state_delta_event(self): + rt = _make_runtime() + event = Event(author="asst") + event.actions.state_delta = {"key": "val"} + events = rt._convert_event(event) + metadata = events[0].metadata + assert metadata is not None + assert metadata["event_type"] == "state_delta" + assert events[0].payload == {"key": "val"} + + +class TestExecute: + @pytest.mark.asyncio + async def test_execute_returns_final_text(self, monkeypatch): + rt = _make_runtime() + + async def fake_run(**kwargs): + yield Event( + author="asst", + content=types.Content( + role="model", parts=[types.Part(text="the answer")] + ), + ) + + monkeypatch.setattr(rt._runner, "run_async", fake_run) + result = await rt.execute({"messages": "q"}) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert isinstance(result.output, dict) + assert result.output["messages"][0]["contentParts"][0]["data"]["inline"] == ( + "the answer" + ) + + @pytest.mark.asyncio + async def test_execute_wraps_errors(self, monkeypatch): + rt = _make_runtime() + + async def failing_run(**kwargs): + raise RuntimeError("kaboom") + yield # pragma: no cover + + monkeypatch.setattr(rt._runner, "run_async", failing_run) + with pytest.raises(UiPathGoogleADKRuntimeError): + await rt.execute({"messages": "q"}) + + +class TestStream: + @pytest.mark.asyncio + async def test_stream_emits_lifecycle_and_success(self, monkeypatch): + rt = _make_runtime() + + async def fake_run(**kwargs): + yield Event( + author="asst", + content=types.Content(role="model", parts=[types.Part(text="done")]), + ) + + monkeypatch.setattr(rt._runner, "run_async", fake_run) + events = [e async for e in rt.stream({"messages": "q"})] + state_events = [e for e in events if isinstance(e, UiPathRuntimeStateEvent)] + assert state_events # graph lifecycle events emitted + # last event is the success result + result = events[-1] + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + + +class TestGetSchema: + @pytest.mark.asyncio + async def test_schema_has_input_output_and_graph(self): + rt = _make_runtime() + schema = await rt.get_schema() + assert schema.type == "agent" + assert schema.file_path == "asst" + assert schema.graph is not None diff --git a/packages/uipath-llamaindex/pyproject.toml b/packages/uipath-llamaindex/pyproject.toml index 7a03a6ac..645cefd0 100644 --- a/packages/uipath-llamaindex/pyproject.toml +++ b/packages/uipath-llamaindex/pyproject.toml @@ -64,6 +64,13 @@ dev = [ "virtualenv>=20.36.1", "pytest-asyncio>=1.0.0", "numpy>=1.24.0", + # Optional-extra backends, required so the vertex/bedrock LLM tests are collectable. + "llama-index-llms-bedrock>=0.3.0", + "llama-index-llms-bedrock-converse>=0.3.0", + "boto3>=1.28.0", + "aiobotocore>=2.5.0", + "llama-index-llms-google-genai>=0.8.0", + "google-genai>=1.0.0", ] [tool.ruff] diff --git a/packages/uipath-llamaindex/tests/embeddings/__init__.py b/packages/uipath-llamaindex/tests/embeddings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-llamaindex/tests/embeddings/test_openai_embedding.py b/packages/uipath-llamaindex/tests/embeddings/test_openai_embedding.py new file mode 100644 index 00000000..17d1e8e0 --- /dev/null +++ b/packages/uipath-llamaindex/tests/embeddings/test_openai_embedding.py @@ -0,0 +1,41 @@ +"""Tests for UiPathOpenAIEmbedding gateway configuration.""" + +from unittest.mock import patch + +import pytest + +from uipath_llamaindex.embeddings._openai import ( + OpenAIEmbeddingModel, + UiPathOpenAIEmbedding, +) + + +def test_init_raises_when_uipath_url_missing(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL environment variable is not set"): + UiPathOpenAIEmbedding() + + +def test_init_maps_enum_model_and_builds_gateway_defaults(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant/") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "secret-token") + + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + # Avoid the real Azure init which performs a network capability lookup. + with patch( + "uipath_llamaindex.embeddings._openai.AzureOpenAIEmbedding.__init__", + fake_init, + ): + UiPathOpenAIEmbedding(model=OpenAIEmbeddingModel.TEXT_EMBEDDING_3_LARGE) + + assert captured["model"] == "text-embedding-3-large" + assert captured["deployment_name"] == "text-embedding-3-large" + assert ( + captured["azure_endpoint"] == "https://cloud.uipath.com/org/tenant/llmgateway_/" + ) + assert captured["api_key"] == "secret-token" + assert captured["default_headers"]["X-UIPATH-STREAMING-ENABLED"] == "false" diff --git a/packages/uipath-llamaindex/tests/llms/__init__.py b/packages/uipath-llamaindex/tests/llms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-llamaindex/tests/llms/test_bedrock.py b/packages/uipath-llamaindex/tests/llms/test_bedrock.py new file mode 100644 index 00000000..e43eb6b4 --- /dev/null +++ b/packages/uipath-llamaindex/tests/llms/test_bedrock.py @@ -0,0 +1,80 @@ +"""Tests for the AWS Bedrock passthrough client and UiPath Bedrock LLMs.""" + +from types import SimpleNamespace + +import pytest + +from uipath_llamaindex.llms.bedrock import ( + AwsBedrockCompletionsPassthroughClient, + UiPathChatBedrock, + UiPathChatBedrockConverse, +) +from uipath_llamaindex.llms.supported_models import BedrockModel + + +def _make_request(url: str) -> SimpleNamespace: + return SimpleNamespace(url=url, headers={}) + + +def test_build_base_url_raises_without_uipath_url(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + client = AwsBedrockCompletionsPassthroughClient("model", "token", "invoke") + with pytest.raises(ValueError, match="UIPATH_URL environment variable is required"): + client._build_base_url() + + +def test_build_base_url_appends_vendor_endpoint(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant/") + client = AwsBedrockCompletionsPassthroughClient("my-model", "token", "invoke") + + url = client._build_base_url() + + assert url.startswith("https://cloud.uipath.com/org/tenant/") + assert "awsbedrock" in url + assert "my-model" in url + + +def test_modify_request_marks_streaming_and_sets_headers(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + monkeypatch.setenv("UIPATH_JOB_KEY", "job-9") + monkeypatch.delenv("UIPATH_PROCESS_KEY", raising=False) + client = AwsBedrockCompletionsPassthroughClient("m", "tok", "converse") + + streaming_req = _make_request("https://aws/model/m/converse-stream") + client._modify_request(streaming_req) + + assert streaming_req.url == client._build_base_url() + assert streaming_req.headers["X-UiPath-Streaming-Enabled"] == "true" + assert streaming_req.headers["Authorization"] == "Bearer tok" + assert streaming_req.headers["X-UiPath-LlmGateway-ApiFlavor"] == "converse" + assert streaming_req.headers["X-UiPath-JobKey"] == "job-9" + assert "X-UiPath-ProcessKey" not in streaming_req.headers + + +def test_modify_request_marks_non_streaming(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + client = AwsBedrockCompletionsPassthroughClient("m", "tok", "invoke") + + req = _make_request("https://aws/model/m/invoke") + client._modify_request(req) + + assert req.headers["X-UiPath-Streaming-Enabled"] == "false" + + +def test_chat_bedrock_requires_token(monkeypatch): + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org") + monkeypatch.setenv("UIPATH_TENANT_ID", "ten") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + with pytest.raises(ValueError, match="UIPATH_ACCESS_TOKEN"): + UiPathChatBedrock() + + +def test_chat_bedrock_converse_constructs_with_default_model(monkeypatch): + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org") + monkeypatch.setenv("UIPATH_TENANT_ID", "ten") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + + llm = UiPathChatBedrockConverse() + + assert llm.model == BedrockModel.anthropic_claude_haiku_4_5 diff --git a/packages/uipath-llamaindex/tests/llms/test_openai.py b/packages/uipath-llamaindex/tests/llms/test_openai.py new file mode 100644 index 00000000..c917ee57 --- /dev/null +++ b/packages/uipath-llamaindex/tests/llms/test_openai.py @@ -0,0 +1,71 @@ +"""Tests for UiPathOpenAI LLM and its URL-rewriting transports.""" + +from unittest.mock import patch + +import httpx +import pytest + +from uipath_llamaindex.llms._openai import ( + UiPathOpenAI, + _UiPathAsyncURLRewriteTransport, + _UiPathSyncURLRewriteTransport, +) +from uipath_llamaindex.llms.supported_models import OpenAIModel + + +def test_sync_transport_rewrites_deployment_url_and_keeps_query(): + transport = _UiPathSyncURLRewriteTransport() + request = httpx.Request( + "POST", + "https://gw.example.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21", + ) + with patch.object( + httpx.HTTPTransport, "handle_request", return_value=httpx.Response(200) + ) as mocked: + transport.handle_request(request) + + forwarded = mocked.call_args.args[0] + assert ( + str(forwarded.url) + == "https://gw.example.com/completions?api-version=2024-10-21" + ) + + +@pytest.mark.asyncio +async def test_async_transport_rewrites_deployment_url_without_query(): + transport = _UiPathAsyncURLRewriteTransport() + request = httpx.Request( + "POST", + "https://gw.example.com/openai/deployments/gpt-4o/chat/completions", + ) + + async def fake_handle(self, req): + return httpx.Response(200) + + with patch.object( + httpx.AsyncHTTPTransport, "handle_async_request", new=fake_handle + ): + # Re-implement capture since new= replaces the method; assert via request mutation + await transport.handle_async_request(request) + + assert str(request.url) == "https://gw.example.com/completions" + + +def test_init_raises_when_uipath_url_missing(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + with pytest.raises(ValueError, match="UIPATH_URL environment variable is not set"): + UiPathOpenAI() + + +def test_init_builds_gateway_endpoint_from_enum_model(monkeypatch): + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant/") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "token") + + llm = UiPathOpenAI(model=OpenAIModel.GPT_4O_2024_08_06) + + assert llm.model == "gpt-4o-2024-08-06" + # trailing slash stripped and vendor endpoint appended, no duplicate /completions + assert llm.azure_endpoint.startswith("https://cloud.uipath.com/org/tenant/") + assert "openai" in llm.azure_endpoint + assert not llm.azure_endpoint.endswith("/completions") diff --git a/packages/uipath-llamaindex/tests/llms/test_supported_models.py b/packages/uipath-llamaindex/tests/llms/test_supported_models.py new file mode 100644 index 00000000..60ba1eef --- /dev/null +++ b/packages/uipath-llamaindex/tests/llms/test_supported_models.py @@ -0,0 +1,20 @@ +"""Tests for the supported model identifier registries.""" + +from uipath_llamaindex.llms.supported_models import ( + BedrockModel, + GeminiModel, + OpenAIModel, +) + + +def test_openai_model_enum_exposes_string_values(): + assert OpenAIModel.GPT_4_1_2025_04_14.value == "gpt-4.1-2025-04-14" + assert OpenAIModel("gpt-4o-mini-2024-07-18") is OpenAIModel.GPT_4O_MINI_2024_07_18 + + +def test_gemini_and_bedrock_identifiers_are_plain_strings(): + assert GeminiModel.gemini_2_5_flash == "gemini-2.5-flash" + assert ( + BedrockModel.anthropic_claude_sonnet_4_5 + == "anthropic.claude-sonnet-4-5-20250929-v1:0" + ) diff --git a/packages/uipath-llamaindex/tests/llms/test_vertex.py b/packages/uipath-llamaindex/tests/llms/test_vertex.py new file mode 100644 index 00000000..5f34a985 --- /dev/null +++ b/packages/uipath-llamaindex/tests/llms/test_vertex.py @@ -0,0 +1,112 @@ +"""Tests for UiPathVertex LLM and its gateway request rewriting.""" + +from unittest.mock import patch + +import httpx +import pytest +from llama_index.core.base.llms.types import ChatMessage, ChatResponse + +from uipath_llamaindex.llms.supported_models import GeminiModel +from uipath_llamaindex.llms.vertex import ( + UiPathVertex, + _rewrite_request_for_gateway, +) + + +def test_rewrite_request_redirects_streaming_and_sets_flag(): + gateway = "https://cloud.uipath.com/gw/vertexai" + request = httpx.Request( + "POST", + "https://generativelanguage.googleapis.com/v1/models/x:streamGenerateContent", + headers={"host": "generativelanguage.googleapis.com"}, + content=b"{}", + ) + + rewritten = _rewrite_request_for_gateway(request, gateway) + + assert str(rewritten.url) == gateway + assert rewritten.headers["X-UiPath-Streaming-Enabled"] == "true" + assert rewritten.headers["host"] == "cloud.uipath.com" + + +def test_rewrite_request_leaves_unrelated_requests_untouched(): + gateway = "https://cloud.uipath.com/gw/vertexai" + request = httpx.Request("GET", "https://example.com/models") + + assert _rewrite_request_for_gateway(request, gateway) is request + + +def test_build_headers_includes_job_and_process_keys(monkeypatch): + monkeypatch.setenv("UIPATH_JOB_KEY", "job-1") + monkeypatch.setenv("UIPATH_PROCESS_KEY", "proc-1") + + headers = UiPathVertex._build_headers_static("my-token") + + assert headers["Authorization"] == "Bearer my-token" + assert headers["X-UiPath-JobKey"] == "job-1" + assert headers["X-UiPath-ProcessKey"] == "proc-1" + + +def test_build_base_url_raises_without_uipath_url(monkeypatch): + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL environment variable is required"): + UiPathVertex._build_base_url_static("gemini-2.5-flash") + + +def test_init_requires_org_id(monkeypatch): + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", "ten") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + UiPathVertex() + + +def _make_vertex(monkeypatch): + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org") + monkeypatch.setenv("UIPATH_TENANT_ID", "ten") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + return UiPathVertex() + + +def test_stream_chat_yields_single_full_response(monkeypatch): + llm = _make_vertex(monkeypatch) + chat_response = ChatResponse(message=ChatMessage(role="assistant", content="hi")) + + with patch.object(UiPathVertex, "chat", return_value=chat_response): + chunks = list(llm.stream_chat([ChatMessage(role="user", content="q")])) + + assert len(chunks) == 1 + assert chunks[0].delta == "hi" + assert chunks[0].message.content == "hi" + + +def test_stream_complete_yields_single_full_response(monkeypatch): + llm = _make_vertex(monkeypatch) + chat_response = ChatResponse(message=ChatMessage(role="assistant", content="text")) + + with patch.object(UiPathVertex, "chat", return_value=chat_response): + chunks = list(llm.stream_complete("prompt")) + + assert len(chunks) == 1 + assert chunks[0].text == "text" + assert chunks[0].delta == "text" + + +def test_complete_delegates_to_chat(monkeypatch): + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org") + monkeypatch.setenv("UIPATH_TENANT_ID", "ten") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com") + + llm = UiPathVertex(model=GeminiModel.gemini_2_5_pro) + assert llm.model == "gemini-2.5-pro" + + chat_response = ChatResponse(message=ChatMessage(role="assistant", content="Paris")) + with patch.object(UiPathVertex, "chat", return_value=chat_response) as mocked_chat: + result = llm.complete("What is the capital of France?") + + assert result.text == "Paris" + sent_messages = mocked_chat.call_args.args[0] + assert sent_messages[0].content == "What is the capital of France?" diff --git a/packages/uipath-llamaindex/tests/models/__init__.py b/packages/uipath-llamaindex/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-llamaindex/tests/models/test_events.py b/packages/uipath-llamaindex/tests/models/test_events.py new file mode 100644 index 00000000..3b1f0165 --- /dev/null +++ b/packages/uipath-llamaindex/tests/models/test_events.py @@ -0,0 +1,29 @@ +"""Tests for the UiPath LlamaIndex event models.""" + +from workflows.events import InputRequiredEvent + +from uipath_llamaindex.models import ( + CreateTaskEvent, + InvokeProcessEvent, + WaitJobEvent, + WaitTaskEvent, +) + + +def test_event_models_are_input_required_events(): + # Each event mixes a UiPath platform action with InputRequiredEvent so the + # workflow engine treats it as a human/external interaction point. + for event_cls in ( + CreateTaskEvent, + WaitTaskEvent, + InvokeProcessEvent, + WaitJobEvent, + ): + assert issubclass(event_cls, InputRequiredEvent) + + +def test_invoke_process_event_carries_process_fields(): + event = InvokeProcessEvent(name="MyProcess", input_arguments={"topic": "UiPath"}) + + assert event.name == "MyProcess" + assert event.input_arguments == {"topic": "UiPath"} diff --git a/packages/uipath-llamaindex/tests/query_engines/__init__.py b/packages/uipath-llamaindex/tests/query_engines/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-llamaindex/tests/query_engines/test_context_grounding_query_engine.py b/packages/uipath-llamaindex/tests/query_engines/test_context_grounding_query_engine.py new file mode 100644 index 00000000..492bc39f --- /dev/null +++ b/packages/uipath-llamaindex/tests/query_engines/test_context_grounding_query_engine.py @@ -0,0 +1,47 @@ +"""Tests for ContextGroundingQueryEngine.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from uipath_llamaindex.query_engines import ContextGroundingQueryEngine + + +def _make_engine(uipath): + synthesizer = MagicMock() + synthesizer.synthesize.return_value = "synthesized answer" + engine = ContextGroundingQueryEngine( + response_synthesizer=synthesizer, + index_name="idx", + folder_path="Shared", + uipath=uipath, + ) + return engine, synthesizer + + +def test_custom_query_retrieves_then_synthesizes(): + uipath = MagicMock() + uipath.context_grounding.search.return_value = [] + engine, synthesizer = _make_engine(uipath) + + with patch.object(engine._retriever, "retrieve", return_value=["node"]) as retrieve: + result = engine.custom_query("question") + + retrieve.assert_called_once_with("question") + synthesizer.synthesize.assert_called_once_with("question", ["node"]) + assert result == "synthesized answer" + + +@pytest.mark.asyncio +async def test_acustom_query_uses_async_retrieve(): + uipath = MagicMock() + engine, synthesizer = _make_engine(uipath) + + async def fake_aretrieve(query_str): + return ["async-node"] + + with patch.object(engine._retriever, "aretrieve", side_effect=fake_aretrieve): + result = await engine.acustom_query("question") + + synthesizer.synthesize.assert_called_once_with("question", ["async-node"]) + assert result == "synthesized answer" diff --git a/packages/uipath-llamaindex/tests/retrievers/__init__.py b/packages/uipath-llamaindex/tests/retrievers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-llamaindex/tests/retrievers/test_context_grounding_retriever.py b/packages/uipath-llamaindex/tests/retrievers/test_context_grounding_retriever.py new file mode 100644 index 00000000..6899c4bb --- /dev/null +++ b/packages/uipath-llamaindex/tests/retrievers/test_context_grounding_retriever.py @@ -0,0 +1,60 @@ +"""Tests for ContextGroundingRetriever.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from llama_index.core.schema import QueryBundle + +from uipath_llamaindex.retrievers import ContextGroundingRetriever + + +def _chunk(content: str, score: float): + return SimpleNamespace( + content=content, + source_document_id="doc-1", + source="source.pdf", + page_number=3, + score=score, + ) + + +def test_retrieve_maps_search_results_to_scored_nodes(): + uipath = MagicMock() + uipath.context_grounding.search.return_value = [ + _chunk("first", 0.9), + _chunk("second", 0.4), + ] + + retriever = ContextGroundingRetriever( + index_name="my-index", + folder_path="Shared", + uipath=uipath, + number_of_results=5, + ) + + nodes = retriever._retrieve(QueryBundle(query_str="what is X?")) + + uipath.context_grounding.search.assert_called_once_with( + "my-index", "what is X?", 5, folder_path="Shared", folder_key=None + ) + assert [n.node.get_content() for n in nodes] == ["first", "second"] + assert nodes[0].score == 0.9 + assert nodes[0].node.metadata["source_document_id"] == "doc-1" + assert nodes[0].node.metadata["page_number"] == 3 + + +@pytest.mark.asyncio +async def test_aretrieve_uses_async_search(): + uipath = MagicMock() + uipath.context_grounding.search_async = AsyncMock( + return_value=[_chunk("async chunk", 0.7)] + ) + + retriever = ContextGroundingRetriever(index_name="idx", uipath=uipath) + + nodes = await retriever._aretrieve(QueryBundle(query_str="q")) + + uipath.context_grounding.search_async.assert_awaited_once() + assert nodes[0].node.get_content() == "async chunk" + assert nodes[0].score == 0.7 diff --git a/packages/uipath-llamaindex/tests/runtime/test_breakpoints.py b/packages/uipath-llamaindex/tests/runtime/test_breakpoints.py new file mode 100644 index 00000000..2a5a18e5 --- /dev/null +++ b/packages/uipath-llamaindex/tests/runtime/test_breakpoints.py @@ -0,0 +1,52 @@ +"""Tests for breakpoint injection wrappers.""" + +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest +from workflows import Context +from workflows.decorators import StepFunction +from workflows.events import HumanResponseEvent + +from uipath_llamaindex.runtime.breakpoints import ( + BreakpointEvent, + BreakpointResumeEvent, + make_wrapper, +) + + +def test_breakpoint_event_stores_node_name(): + event = BreakpointEvent(breakpoint_node="my_step") + assert event.breakpoint_node == "my_step" + + +@pytest.mark.asyncio +async def test_wrapper_suspends_then_runs_original_step(monkeypatch): + calls = {} + + async def original(self: Any, *args: Any, **kwargs: Any) -> Any: + calls["ran"] = True + return "step-result" + + # make_wrapper copies the original step config onto the wrapper. + step_config = object() + original._step_config = step_config # type: ignore[attr-defined] + + wrapped = make_wrapper("my_step", cast(StepFunction[..., Any], original)) + assert wrapped._step_config is step_config + + fake_ctx = AsyncMock() + monkeypatch.setattr(Context, "get_step_context", lambda: fake_ctx) + result = await wrapped(object()) + + assert result == "step-result" + assert calls["ran"] is True + # It waits for the debugger's resume event before running the step. + fake_ctx.wait_for_event.assert_awaited_once() + awaited_kwargs = fake_ctx.wait_for_event.await_args.kwargs + assert awaited_kwargs["waiter_id"] == "bp_my_step" + assert isinstance(awaited_kwargs["waiter_event"], BreakpointEvent) + + +def test_resume_event_is_distinct_type(): + assert issubclass(BreakpointResumeEvent, HumanResponseEvent) diff --git a/packages/uipath-llamaindex/tests/runtime/test_config.py b/packages/uipath-llamaindex/tests/runtime/test_config.py new file mode 100644 index 00000000..23eddb0c --- /dev/null +++ b/packages/uipath-llamaindex/tests/runtime/test_config.py @@ -0,0 +1,49 @@ +"""Tests for LlamaIndexConfig loader.""" + +import json +import os + +import pytest + +from uipath_llamaindex.runtime.config import LlamaIndexConfig + + +def _write(tmp_path, content: str) -> str: + path = os.path.join(tmp_path, "llama_index.json") + with open(path, "w") as f: + f.write(content) + return path + + +def test_workflows_and_entrypoints_loaded(tmp_path): + path = _write(tmp_path, json.dumps({"workflows": {"agent": "main.py:workflow"}})) + config = LlamaIndexConfig(config_path=path) + + assert config.exists is True + assert config.workflows == {"agent": "main.py:workflow"} + assert config.entrypoints == ["agent"] + + +def test_missing_file_raises_file_not_found(tmp_path): + config = LlamaIndexConfig(config_path=os.path.join(tmp_path, "nope.json")) + assert config.exists is False + with pytest.raises(FileNotFoundError): + _ = config.workflows + + +def test_missing_workflows_field_raises_value_error(tmp_path): + path = _write(tmp_path, json.dumps({"other": {}})) + with pytest.raises(ValueError, match="Missing required 'workflows' field"): + _ = LlamaIndexConfig(config_path=path).workflows + + +def test_workflows_must_be_a_dict(tmp_path): + path = _write(tmp_path, json.dumps({"workflows": ["a", "b"]})) + with pytest.raises(ValueError, match="'workflows' must be a dictionary"): + _ = LlamaIndexConfig(config_path=path).workflows + + +def test_invalid_json_raises_value_error(tmp_path): + path = _write(tmp_path, "{not valid json") + with pytest.raises(ValueError, match="Invalid JSON"): + _ = LlamaIndexConfig(config_path=path).workflows diff --git a/packages/uipath-llamaindex/tests/runtime/test_errors.py b/packages/uipath-llamaindex/tests/runtime/test_errors.py new file mode 100644 index 00000000..b05db777 --- /dev/null +++ b/packages/uipath-llamaindex/tests/runtime/test_errors.py @@ -0,0 +1,21 @@ +"""Tests for the LlamaIndex runtime error type.""" + +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_llamaindex.runtime.errors import ( + UiPathLlamaIndexErrorCode, + UiPathLlamaIndexRuntimeError, +) + + +def test_runtime_error_carries_structured_fields(): + error = UiPathLlamaIndexRuntimeError( + code=UiPathLlamaIndexErrorCode.WORKFLOW_NOT_FOUND, + title="Not found", + detail="workflow missing", + category=UiPathErrorCategory.USER, + ) + + assert error.error_info.code == "LlamaIndex.WORKFLOW_NOT_FOUND" + assert error.error_info.title == "Not found" + assert error.error_info.detail == "workflow missing" diff --git a/packages/uipath-llamaindex/tests/runtime/test_telemetry.py b/packages/uipath-llamaindex/tests/runtime/test_telemetry.py new file mode 100644 index 00000000..a324f07b --- /dev/null +++ b/packages/uipath-llamaindex/tests/runtime/test_telemetry.py @@ -0,0 +1,52 @@ +"""Tests for the ToolCallAttributeNormalizer span processor.""" + +import json +from types import SimpleNamespace +from typing import Any, cast + +from opentelemetry.sdk.trace import ReadableSpan + +from uipath_llamaindex.runtime._telemetry import ToolCallAttributeNormalizer + + +def _tool_span(attributes: dict[str, Any]) -> ReadableSpan: + return cast(ReadableSpan, SimpleNamespace(name="tool-span", _attributes=attributes)) + + +def test_input_value_kwargs_wrapper_is_unwrapped(): + attrs = { + "openinference.span.kind": "TOOL", + "input.value": json.dumps({"kwargs": {"city": "Paris"}}), + } + ToolCallAttributeNormalizer().on_end(_tool_span(attrs)) + + assert json.loads(attrs["input.value"]) == {"city": "Paris"} + + +def test_output_value_is_reshaped_to_common_format(): + attrs = { + "openinference.span.kind": "TOOL", + "output.value": json.dumps( + {"raw_output": "sunny", "is_error": False, "tool_call_id": "call-1"} + ), + } + ToolCallAttributeNormalizer().on_end(_tool_span(attrs)) + + assert json.loads(attrs["output.value"]) == { + "content": "sunny", + "status": "success", + "tool_call_id": "call-1", + } + + +def test_non_tool_spans_are_left_untouched(): + original = json.dumps({"kwargs": {"a": 1}}) + attrs = {"openinference.span.kind": "LLM", "input.value": original} + ToolCallAttributeNormalizer().on_end(_tool_span(attrs)) + + assert attrs["input.value"] == original + + +def test_span_without_attributes_returns_early(): + # Should not raise when the span exposes no attributes. + ToolCallAttributeNormalizer().on_end(_tool_span({})) diff --git a/packages/uipath-llamaindex/tests/runtime/test_workflow_loader.py b/packages/uipath-llamaindex/tests/runtime/test_workflow_loader.py new file mode 100644 index 00000000..49d46da6 --- /dev/null +++ b/packages/uipath-llamaindex/tests/runtime/test_workflow_loader.py @@ -0,0 +1,96 @@ +"""Tests for LlamaIndexWorkflowLoader.""" + +import textwrap + +import pytest +from workflows import Workflow + +from uipath_llamaindex.runtime.workflow import LlamaIndexWorkflowLoader + +WORKFLOW_MODULE = textwrap.dedent( + """ + from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step + + + class Flow(Workflow): + @step + async def run_step(self, ev: StartEvent) -> StopEvent: + return StopEvent(result="done") + + + workflow = Flow(timeout=60) + not_a_workflow = object() + """ +) + + +def test_from_path_string_splits_file_and_variable(): + loader = LlamaIndexWorkflowLoader.from_path_string("agent", "main.py:workflow") + assert loader.file_path == "main.py" + assert loader.variable_name == "workflow" + + +def test_from_path_string_rejects_missing_separator(): + with pytest.raises(ValueError, match="Invalid path format"): + LlamaIndexWorkflowLoader.from_path_string("agent", "main.py") + + +async def test_load_rejects_file_outside_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + loader = LlamaIndexWorkflowLoader("agent", "/etc/hosts", "workflow") + with pytest.raises(ValueError, match="must be within current directory"): + await loader.load() + + +async def test_load_raises_for_missing_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + loader = LlamaIndexWorkflowLoader("agent", "missing.py", "workflow") + with pytest.raises(FileNotFoundError): + await loader.load() + + +async def test_load_raises_when_variable_absent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "flow_a.py").write_text(WORKFLOW_MODULE) + loader = LlamaIndexWorkflowLoader("agent", "flow_a.py", "nonexistent") + with pytest.raises(AttributeError, match="not found"): + await loader.load() + + +async def test_load_raises_type_error_for_non_workflow(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "flow_b.py").write_text(WORKFLOW_MODULE) + loader = LlamaIndexWorkflowLoader("agent", "flow_b.py", "not_a_workflow") + with pytest.raises(TypeError, match="Expected Workflow"): + await loader.load() + + +async def test_load_returns_workflow_instance(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "flow_c.py").write_text(WORKFLOW_MODULE) + loader = LlamaIndexWorkflowLoader("agent", "flow_c.py", "workflow") + + workflow = await loader.load() + + assert isinstance(workflow, Workflow) + + +async def test_resolve_workflow_handles_async_context_manager(): + loader = LlamaIndexWorkflowLoader("agent", "x.py", "workflow") + entered = {"value": False} + exited = {"value": False} + + class CtxManager: + async def __aenter__(self): + entered["value"] = True + return "the-workflow" + + async def __aexit__(self, *args): + exited["value"] = True + + resolved = await loader._resolve_workflow(CtxManager()) + assert resolved == "the-workflow" + assert entered["value"] is True + + await loader.cleanup() + assert exited["value"] is True diff --git a/packages/uipath-llamaindex/uv.lock b/packages/uipath-llamaindex/uv.lock index 8ab66a15..96939dc9 100644 --- a/packages/uipath-llamaindex/uv.lock +++ b/packages/uipath-llamaindex/uv.lock @@ -3571,7 +3571,13 @@ vertex = [ [package.dev-dependencies] dev = [ + { name = "aiobotocore" }, + { name = "boto3" }, { name = "filelock" }, + { name = "google-genai" }, + { name = "llama-index-llms-bedrock" }, + { name = "llama-index-llms-bedrock-converse" }, + { name = "llama-index-llms-google-genai" }, { name = "mypy" }, { name = "numpy" }, { name = "pre-commit" }, @@ -3604,7 +3610,13 @@ provides-extras = ["bedrock", "vertex"] [package.metadata.requires-dev] dev = [ + { name = "aiobotocore", specifier = ">=2.5.0" }, + { name = "boto3", specifier = ">=1.28.0" }, { name = "filelock", specifier = ">=3.20.3" }, + { name = "google-genai", specifier = ">=1.0.0" }, + { name = "llama-index-llms-bedrock", specifier = ">=0.3.0" }, + { name = "llama-index-llms-bedrock-converse", specifier = ">=0.3.0" }, + { name = "llama-index-llms-google-genai", specifier = ">=0.8.0" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "numpy", specifier = ">=1.24.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, diff --git a/packages/uipath-openai-agents/tests/test_agent_loader.py b/packages/uipath-openai-agents/tests/test_agent_loader.py new file mode 100644 index 00000000..af10b45d --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_agent_loader.py @@ -0,0 +1,90 @@ +"""Tests for OpenAiAgentLoader path parsing, loading and resolution.""" + +import pytest +from agents import Agent + +from uipath_openai_agents.runtime.agent import OpenAiAgentLoader +from uipath_openai_agents.runtime.errors import ( + UiPathOpenAIAgentsErrorCode, + UiPathOpenAIAgentsRuntimeError, +) + + +def test_from_path_string_rejects_missing_variable_separator() -> None: + """A path without ':' is rejected as an invalid config format.""" + with pytest.raises(UiPathOpenAIAgentsRuntimeError) as exc: + OpenAiAgentLoader.from_path_string("agent", "main.py") + + assert exc.value.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.CONFIG_INVALID.value + ) + + +def test_from_path_string_splits_file_and_variable() -> None: + """A valid 'file:variable' path is split into its components.""" + loader = OpenAiAgentLoader.from_path_string("agent", "pkg/main.py:my_agent") + + assert loader.file_path == "pkg/main.py" + assert loader.variable_name == "my_agent" + + +@pytest.mark.asyncio +async def test_load_rejects_file_outside_working_directory(tmp_path) -> None: + """Loading a file outside the cwd is refused for safety.""" + loader = OpenAiAgentLoader("agent", "/etc/hosts", "agent") + + with pytest.raises(UiPathOpenAIAgentsRuntimeError) as exc: + await loader.load() + + assert exc.value.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.AGENT_VALUE_ERROR.value + ) + + +@pytest.mark.asyncio +async def test_load_reports_missing_file( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """A non-existent agent file inside the cwd raises AGENT_NOT_FOUND.""" + monkeypatch.chdir(tmp_path) + loader = OpenAiAgentLoader("agent", "missing.py", "agent") + + with pytest.raises(UiPathOpenAIAgentsRuntimeError) as exc: + await loader.load() + + assert exc.value.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.AGENT_NOT_FOUND.value + ) + + +@pytest.mark.asyncio +async def test_resolve_agent_invokes_sync_factory() -> None: + """A callable returning an Agent is invoked and its result used.""" + agent = Agent(name="factory", instructions="x") + loader = OpenAiAgentLoader("agent", "main.py", "agent") + + resolved = await loader._resolve_agent(lambda: agent) + + assert resolved is agent + + +@pytest.mark.asyncio +async def test_resolve_agent_enters_async_context_manager_and_cleans_up() -> None: + """An async context manager is entered on resolve and exited on cleanup.""" + agent = Agent(name="ctx", instructions="x") + exited = [] + + class AgentCM: + async def __aenter__(self): + return agent + + async def __aexit__(self, *exc): + exited.append(True) + + loader = OpenAiAgentLoader("agent", "main.py", "agent") + + resolved = await loader._resolve_agent(AgentCM()) + assert resolved is agent + + await loader.cleanup() + assert exited == [True] diff --git a/packages/uipath-openai-agents/tests/test_chat_openai.py b/packages/uipath-openai-agents/tests/test_chat_openai.py new file mode 100644 index 00000000..af737787 --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_chat_openai.py @@ -0,0 +1,74 @@ +"""Tests for the UiPath OpenAI chat client and URL rewriting.""" + +import httpx +import pytest + +from uipath_openai_agents.chat.openai import ( + UiPathChatOpenAI, + _rewrite_openai_url, +) + + +def test_rewrite_responses_url_to_completions() -> None: + """A /responses URL is rewritten to the gateway /completions endpoint.""" + rewritten = _rewrite_openai_url( + "https://host/llm/openai/gpt-4o/responses", httpx.QueryParams() + ) + + assert str(rewritten) == "https://host/llm/openai/gpt-4o/completions" + + +def test_rewrite_preserves_query_params() -> None: + """Query params (e.g. api-version) are preserved on the rewritten URL.""" + params = httpx.QueryParams({"api-version": "2024-12-01-preview"}) + rewritten = _rewrite_openai_url("https://host/base?api-version=x", params) + + assert rewritten is not None + assert rewritten.path == "/base/completions" + assert rewritten.params["api-version"] == "2024-12-01-preview" + + +@pytest.fixture +def chat_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_ACCESS_TOKEN"): + monkeypatch.delenv(var, raising=False) + + +def test_missing_org_id_raises_value_error(chat_env: None) -> None: + """Construction fails clearly when no organization id is available.""" + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + UiPathChatOpenAI(token="t", tenant_id="tn") + + +def test_build_headers_includes_optional_and_respects_overrides( + chat_env: None, +) -> None: + """Headers carry flavor/auth plus optional agenthub/byo, with extra_headers winning.""" + client = UiPathChatOpenAI( + token="my-token", + org_id="org", + tenant_id="tn", + api_flavor="responses", + agenthub_config="cfg", + byo_connection_id="conn-1", + extra_headers={"X-UiPath-LlmGateway-ApiFlavor": "chat-completions"}, + ) + + headers = client._build_headers() + + assert headers["Authorization"] == "Bearer my-token" + assert headers["X-UiPath-AgentHub-Config"] == "cfg" + assert headers["X-UiPath-LlmGateway-ByoIsConnectionId"] == "conn-1" + # extra_headers override the default flavor + assert headers["X-UiPath-LlmGateway-ApiFlavor"] == "chat-completions" + + +def test_missing_uipath_url_raises_value_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Base URL construction requires the UIPATH_URL environment variable.""" + monkeypatch.delenv("UIPATH_URL", raising=False) + + with pytest.raises(ValueError, match="UIPATH_URL"): + UiPathChatOpenAI(token="t", org_id="org", tenant_id="tn") diff --git a/packages/uipath-openai-agents/tests/test_cli_new.py b/packages/uipath-openai-agents/tests/test_cli_new.py new file mode 100644 index 00000000..5918ede6 --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_cli_new.py @@ -0,0 +1,43 @@ +"""Tests for the `uipath new` OpenAI agents scaffolding middleware.""" + +import os + +import pytest + +from uipath_openai_agents._cli import cli_new +from uipath_openai_agents._cli.cli_new import openai_agents_new_middleware + + +def test_middleware_scaffolds_project_files( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """The middleware writes main.py, config, AGENTS.md and pyproject into cwd.""" + monkeypatch.chdir(tmp_path) + + result = openai_agents_new_middleware("my_agent") + + assert result.should_continue is False + for name in ("main.py", "openai_agents.json", "AGENTS.md", "pyproject.toml"): + assert os.path.exists(tmp_path / name) + # project name is threaded into the generated pyproject + assert 'name = "my_agent"' in (tmp_path / "pyproject.toml").read_text() + + +def test_middleware_reports_stacktrace_on_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + """A generation failure returns a non-continuing result requesting a stacktrace.""" + monkeypatch.chdir(tmp_path) + + def boom(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(cli_new, "generate_pyproject", boom) + errors: list[str] = [] + monkeypatch.setattr(cli_new.console, "error", lambda msg, **kw: errors.append(msg)) + + result = openai_agents_new_middleware("my_agent") + + assert errors and "disk full" in errors[0] + assert result.should_continue is False + assert result.should_include_stacktrace is True diff --git a/packages/uipath-openai-agents/tests/test_config.py b/packages/uipath-openai-agents/tests/test_config.py new file mode 100644 index 00000000..1c9227a1 --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_config.py @@ -0,0 +1,44 @@ +"""Tests for OpenAiAgentsConfig loading.""" + +import pytest + +from uipath_openai_agents.runtime.config import OpenAiAgentsConfig + + +def test_agents_and_entrypoint_parsed_from_file(tmp_path) -> None: + """A valid config exposes the agents map and derived entrypoint list.""" + cfg_file = tmp_path / "openai_agents.json" + cfg_file.write_text('{"agents": {"basic": "main.py:agent"}}') + + config = OpenAiAgentsConfig(str(cfg_file)) + + assert config.exists is True + assert config.agents == {"basic": "main.py:agent"} + assert config.entrypoint == ["basic"] + + +def test_missing_agents_key_raises_value_error(tmp_path) -> None: + """A config without the 'agents' key is rejected.""" + cfg_file = tmp_path / "openai_agents.json" + cfg_file.write_text('{"other": 1}') + + with pytest.raises(ValueError, match="Missing 'agents' key"): + _ = OpenAiAgentsConfig(str(cfg_file)).agents + + +def test_invalid_json_raises_value_error(tmp_path) -> None: + """Malformed JSON surfaces as a ValueError referencing the path.""" + cfg_file = tmp_path / "openai_agents.json" + cfg_file.write_text("{not json") + + with pytest.raises(ValueError, match="Invalid JSON"): + _ = OpenAiAgentsConfig(str(cfg_file)).agents + + +def test_missing_file_raises_file_not_found(tmp_path) -> None: + """Loading agents from a non-existent config raises FileNotFoundError.""" + config = OpenAiAgentsConfig(str(tmp_path / "nope.json")) + + assert config.exists is False + with pytest.raises(FileNotFoundError): + _ = config.agents diff --git a/packages/uipath-openai-agents/tests/test_factory.py b/packages/uipath-openai-agents/tests/test_factory.py new file mode 100644 index 00000000..d923bc76 --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_factory.py @@ -0,0 +1,108 @@ +"""Tests for UiPathOpenAIAgentRuntimeFactory.""" + +import pytest +from uipath.runtime import UiPathRuntimeContext + +from uipath_openai_agents.runtime.errors import ( + UiPathOpenAIAgentsErrorCode, + UiPathOpenAIAgentsRuntimeError, +) +from uipath_openai_agents.runtime.factory import UiPathOpenAIAgentRuntimeFactory +from uipath_openai_agents.runtime.runtime import UiPathOpenAIAgentRuntime + +MOCK_AGENT = ( + 'from agents import Agent\nagent = Agent(name="basic", instructions="hi")\n' +) + + +@pytest.fixture +def factory() -> UiPathOpenAIAgentRuntimeFactory: + return UiPathOpenAIAgentRuntimeFactory(context=UiPathRuntimeContext()) + + +def _write_project(tmp_path, monkeypatch) -> None: + (tmp_path / "main.py").write_text(MOCK_AGENT) + (tmp_path / "openai_agents.json").write_text( + '{"agents": {"basic": "main.py:agent"}}' + ) + monkeypatch.chdir(tmp_path) + + +def test_discover_entrypoints_empty_without_config( + factory: UiPathOpenAIAgentRuntimeFactory, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """With no openai_agents.json, no entrypoints are discovered.""" + monkeypatch.chdir(tmp_path) + + assert factory.discover_entrypoints() == [] + + +@pytest.mark.asyncio +async def test_load_agent_missing_config_raises_config_missing( + factory: UiPathOpenAIAgentRuntimeFactory, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """Loading an agent without a config file raises CONFIG_MISSING.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(UiPathOpenAIAgentsRuntimeError) as exc: + await factory._load_agent("basic") + + assert exc.value.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.CONFIG_MISSING.value + ) + + +@pytest.mark.asyncio +async def test_load_agent_unknown_entrypoint_raises_agent_not_found( + factory: UiPathOpenAIAgentRuntimeFactory, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """Requesting an entrypoint absent from config raises AGENT_NOT_FOUND.""" + _write_project(tmp_path, monkeypatch) + + with pytest.raises(UiPathOpenAIAgentsRuntimeError) as exc: + await factory._load_agent("nope") + + assert exc.value.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.AGENT_NOT_FOUND.value + ) + + +@pytest.mark.asyncio +async def test_new_runtime_loads_and_caches_agent( + factory: UiPathOpenAIAgentRuntimeFactory, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """new_runtime resolves the configured agent and reuses the cached instance.""" + _write_project(tmp_path, monkeypatch) + + runtime = await factory.new_runtime("basic", "rt-1") + + assert isinstance(runtime, UiPathOpenAIAgentRuntime) + assert runtime.agent.name == "basic" + # second resolution comes from the cache (same Agent object) + runtime2 = await factory.new_runtime("basic", "rt-2") + assert isinstance(runtime2, UiPathOpenAIAgentRuntime) + assert runtime2.agent is runtime.agent + + +@pytest.mark.asyncio +async def test_dispose_clears_caches_and_loaders( + factory: UiPathOpenAIAgentRuntimeFactory, + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """dispose clears cached agents and loaders.""" + _write_project(tmp_path, monkeypatch) + await factory.new_runtime("basic", "rt-1") + + await factory.dispose() + + assert factory._agent_cache == {} + assert factory._agent_loaders == {} diff --git a/packages/uipath-openai-agents/tests/test_placeholder.py b/packages/uipath-openai-agents/tests/test_placeholder.py deleted file mode 100644 index 5ebaae9c..00000000 --- a/packages/uipath-openai-agents/tests/test_placeholder.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Placeholder test file for uipath-openai-agents package.""" - - -def test_placeholder(): - """Placeholder test to ensure pytest runs successfully.""" - assert True diff --git a/packages/uipath-openai-agents/tests/test_runtime.py b/packages/uipath-openai-agents/tests/test_runtime.py new file mode 100644 index 00000000..96abc715 --- /dev/null +++ b/packages/uipath-openai-agents/tests/test_runtime.py @@ -0,0 +1,164 @@ +"""Tests for UiPathOpenAIAgentRuntime execution and event conversion.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from agents import Agent +from pydantic import BaseModel +from uipath.runtime import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.errors import UiPathErrorCode +from uipath.runtime.events import ( + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, +) + +from uipath_openai_agents.runtime.errors import ( + UiPathOpenAIAgentsErrorCode, + UiPathOpenAIAgentsRuntimeError, +) +from uipath_openai_agents.runtime.runtime import UiPathOpenAIAgentRuntime + + +class _Item(BaseModel): + text: str + + +@pytest.fixture +def runtime() -> UiPathOpenAIAgentRuntime: + agent = Agent(name="echo", instructions="echo") + return UiPathOpenAIAgentRuntime(agent=agent, entrypoint="echo") + + +@pytest.mark.asyncio +async def test_execute_wraps_scalar_output_in_result_dict( + runtime: UiPathOpenAIAgentRuntime, monkeypatch: pytest.MonkeyPatch +) -> None: + """execute() serializes a non-dict final_output under a 'result' key.""" + + async def fake_run(**kwargs: Any) -> Any: + return SimpleNamespace(final_output="hello there") + + monkeypatch.setattr("uipath_openai_agents.runtime.runtime.Runner.run", fake_run) + + result = await runtime.execute({"messages": "hi"}) + + assert isinstance(result, UiPathRuntimeResult) + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == {"result": "hello there"} + + +@pytest.mark.asyncio +async def test_stream_emits_message_event_then_final_result( + runtime: UiPathOpenAIAgentRuntime, monkeypatch: pytest.MonkeyPatch +) -> None: + """stream() yields converted events and a terminal success result.""" + stream_event = SimpleNamespace( + type="run_item_stream_event", + name="message_output_created", + item=_Item(text="hi"), + ) + + class FakeStreaming: + final_output = {"answer": 42} + + async def stream_events(self): + yield stream_event + + monkeypatch.setattr( + "uipath_openai_agents.runtime.runtime.Runner.run_streamed", + lambda **kwargs: FakeStreaming(), + ) + + events = [e async for e in runtime.stream({"messages": "hi"})] + + assert isinstance(events[0], UiPathRuntimeMessageEvent) + assert events[0].metadata == {"event_name": "message_output_created"} + assert isinstance(events[-1], UiPathRuntimeResult) + assert events[-1].output == {"answer": 42} + + +def test_convert_agent_updated_event_to_state_event( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """agent_updated_stream_event maps to a state event carrying the new agent name.""" + event = SimpleNamespace( + type="agent_updated_stream_event", + name=None, + new_agent=SimpleNamespace(name="specialist"), + ) + + converted = runtime._convert_stream_event_to_runtime_event(event) + + assert isinstance(converted, UiPathRuntimeStateEvent) + assert converted.payload == {"agent_name": "specialist"} + + +def test_convert_raw_event_is_filtered_out( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """Unrecognized (raw) events are filtered and produce no runtime event.""" + event = SimpleNamespace(type="raw_response_event", name=None) + + assert runtime._convert_stream_event_to_runtime_event(event) is None + + +def test_prepare_input_defaults_when_no_input( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """Missing input yields empty message string and no context.""" + assert runtime._prepare_agent_input_and_context(None) == ("", None) + + +def test_prepare_input_coerces_non_string_messages_to_empty( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """A non-str/non-list 'messages' value is coerced to an empty string.""" + messages, context = runtime._prepare_agent_input_and_context({"messages": 123}) + + assert messages == "" + assert context is None + + +def test_runtime_error_maps_json_decode_error( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """A JSONDecodeError is classified as INPUT_INVALID_JSON.""" + err = json.JSONDecodeError("bad", "doc", 0) + + mapped = runtime._create_runtime_error(err) + + assert mapped.error_info.code.endswith(UiPathErrorCode.INPUT_INVALID_JSON.value) + + +def test_runtime_error_maps_timeout(runtime: UiPathOpenAIAgentRuntime) -> None: + """A TimeoutError is classified as TIMEOUT_ERROR.""" + mapped = runtime._create_runtime_error(TimeoutError("slow")) + + assert mapped.error_info.code.endswith( + UiPathOpenAIAgentsErrorCode.TIMEOUT_ERROR.value + ) + + +def test_runtime_error_passes_through_own_error( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """An existing runtime error is returned unchanged, not re-wrapped.""" + original = UiPathOpenAIAgentsRuntimeError( + UiPathOpenAIAgentsErrorCode.AGENT_EXECUTION_FAILURE, "t", "d" + ) + + assert runtime._create_runtime_error(original) is original + + +@pytest.mark.asyncio +async def test_get_schema_reports_agent_entrypoint_and_messages_input( + runtime: UiPathOpenAIAgentRuntime, +) -> None: + """get_schema exposes the entrypoint path, agent type and messages input.""" + schema = await runtime.get_schema() + + assert schema.file_path == "echo" + assert schema.type == "agent" + assert "messages" in schema.input["properties"] diff --git a/packages/uipath-pydantic-ai/tests/test_chat.py b/packages/uipath-pydantic-ai/tests/test_chat.py new file mode 100644 index 00000000..31bf1e10 --- /dev/null +++ b/packages/uipath-pydantic-ai/tests/test_chat.py @@ -0,0 +1,149 @@ +"""Tests for the UiPath PydanticAI chat client and lazy chat module exports.""" + +import httpx +import pytest + +# ============= LAZY MODULE EXPORT TESTS ============= + + +def test_chat_module_lazy_exports_client(): + """__getattr__ lazily resolves UiPathChatOpenAI to the real class.""" + import uipath_pydantic_ai.chat as chat + from uipath_pydantic_ai.chat.openai import UiPathChatOpenAI + + assert chat.UiPathChatOpenAI is UiPathChatOpenAI + + +def test_chat_module_lazy_exports_models(): + """__getattr__ lazily resolves the supported-model enums.""" + import uipath_pydantic_ai.chat as chat + from uipath_pydantic_ai.chat.supported_models import OpenAIModels + + assert chat.OpenAIModels is OpenAIModels + + +def test_chat_module_unknown_attribute_raises(): + """Accessing an unknown attribute raises AttributeError.""" + import uipath_pydantic_ai.chat as chat + + with pytest.raises(AttributeError): + _ = chat.DoesNotExist + + +# ============= SUPPORTED MODEL ENUM TESTS ============= + + +def test_openai_model_enum_is_string_value(): + """OpenAIModels is a StrEnum whose members equal their wire string.""" + from uipath_pydantic_ai.chat.supported_models import OpenAIModels + + assert OpenAIModels.gpt_4o_2024_11_20 == "gpt-4o-2024-11-20" + + +# ============= URL REWRITE TESTS ============= + + +def test_rewrite_url_responses_endpoint(): + """A /responses path is rewritten to /completions.""" + from uipath_pydantic_ai.chat.openai import _rewrite_openai_url + + result = _rewrite_openai_url( + "https://gw.uipath.com/llm/openai/responses", httpx.QueryParams() + ) + assert str(result) == "https://gw.uipath.com/llm/openai/completions" + + +def test_rewrite_url_chat_completions_endpoint(): + """A /chat/completions path is collapsed to /completions.""" + from uipath_pydantic_ai.chat.openai import _rewrite_openai_url + + result = _rewrite_openai_url( + "https://gw.uipath.com/llm/openai/chat/completions", httpx.QueryParams() + ) + assert str(result) == "https://gw.uipath.com/llm/openai/completions" + + +def test_rewrite_url_preserves_query_params(): + """Query params are carried onto the rewritten URL.""" + from uipath_pydantic_ai.chat.openai import _rewrite_openai_url + + params = httpx.QueryParams({"api-version": "2024-12-01-preview"}) + result = _rewrite_openai_url("https://gw.uipath.com/llm/openai/completions", params) + assert result is not None + assert result.params["api-version"] == "2024-12-01-preview" + + +def test_rewrite_url_base_only_strips_query(): + """A bare base URL (no known path) gets /completions appended.""" + from uipath_pydantic_ai.chat.openai import _rewrite_openai_url + + result = _rewrite_openai_url( + "https://gw.uipath.com/llm/openai?foo=bar", httpx.QueryParams() + ) + assert str(result) == "https://gw.uipath.com/llm/openai/completions" + + +# ============= CLIENT CONSTRUCTION TESTS ============= + + +def test_client_requires_org_id(monkeypatch): + """Missing organization id raises a descriptive ValueError.""" + from uipath_pydantic_ai.chat.openai import UiPathChatOpenAI + + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + UiPathChatOpenAI(tenant_id="t", token="tok") + + +def test_client_build_headers_and_base_url(monkeypatch): + """A fully configured client builds gateway headers and a base URL from UIPATH_URL.""" + from uipath_pydantic_ai.chat.openai import UiPathChatOpenAI + + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant/") + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_KEY", raising=False) + + client = UiPathChatOpenAI( + token="my-token", + org_id="org", + tenant_id="tenant", + agenthub_config="cfg", + byo_connection_id="conn-1", + ) + + headers = client._build_headers() + assert headers["Authorization"] == "Bearer my-token" + assert headers["X-UiPath-LlmGateway-ApiFlavor"] == "chat-completions" + assert headers["X-UiPath-AgentHub-Config"] == "cfg" + assert headers["X-UiPath-LlmGateway-ByoIsConnectionId"] == "conn-1" + + # endpoint has the /completions suffix stripped; base url is prefixed by UIPATH_URL + assert not client.endpoint.endswith("/completions") + assert client._build_base_url() == ( + f"https://cloud.uipath.com/org/tenant/{client.endpoint}" + ) + assert client.model_name == client._model_name + + +def test_client_requires_uipath_url(monkeypatch): + """Building the base URL without UIPATH_URL set raises ValueError.""" + from uipath_pydantic_ai.chat.openai import UiPathChatOpenAI + + monkeypatch.delenv("UIPATH_URL", raising=False) + with pytest.raises(ValueError, match="UIPATH_URL"): + UiPathChatOpenAI(token="tok", org_id="org", tenant_id="tenant") + + +def test_client_extra_headers_override_defaults(monkeypatch): + """extra_headers take precedence over the built-in gateway defaults.""" + from uipath_pydantic_ai.chat.openai import UiPathChatOpenAI + + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + + client = UiPathChatOpenAI( + token="tok", + org_id="org", + tenant_id="tenant", + extra_headers={"X-UiPath-LlmGateway-ApiFlavor": "responses"}, + ) + assert client._build_headers()["X-UiPath-LlmGateway-ApiFlavor"] == "responses" diff --git a/packages/uipath-pydantic-ai/tests/test_cli_new.py b/packages/uipath-pydantic-ai/tests/test_cli_new.py new file mode 100644 index 00000000..ac31b2ea --- /dev/null +++ b/packages/uipath-pydantic-ai/tests/test_cli_new.py @@ -0,0 +1,39 @@ +"""Tests for the `uipath new` PydanticAI scaffolding middleware.""" + +import os +import shutil + +import click +import pytest + +from uipath_pydantic_ai._cli.cli_new import pydantic_ai_new_middleware + + +def test_new_middleware_scaffolds_project(tmp_path, monkeypatch): + """The middleware writes main.py, pydantic_ai.json and pyproject.toml, then stops.""" + monkeypatch.chdir(tmp_path) + + result = pydantic_ai_new_middleware("my-agent") + + assert result.should_continue is False + assert os.path.exists(tmp_path / "main.py") + assert os.path.exists(tmp_path / "pydantic_ai.json") + pyproject = (tmp_path / "pyproject.toml").read_text() + assert 'name = "my-agent"' in pyproject + assert "uipath-pydantic-ai" in pyproject + + +def test_new_middleware_reports_failure(tmp_path, monkeypatch): + """A generation failure is caught and reported via console.error (which exits).""" + monkeypatch.chdir(tmp_path) + + def _boom(*a, **k): + raise OSError("disk full") + + monkeypatch.setattr(shutil, "copyfile", _boom) + + # console.error() calls click's ctx.exit(1); provide an active context so the + # except branch reports the failure instead of raising "no active click context". + with click.Context(click.Command("new")): + with pytest.raises((SystemExit, click.exceptions.Exit)): + pydantic_ai_new_middleware("broken") diff --git a/packages/uipath-pydantic-ai/tests/test_factory_runtime.py b/packages/uipath-pydantic-ai/tests/test_factory_runtime.py new file mode 100644 index 00000000..0ca50f68 --- /dev/null +++ b/packages/uipath-pydantic-ai/tests/test_factory_runtime.py @@ -0,0 +1,120 @@ +"""Tests for the PydanticAI runtime factory orchestration.""" + +import json +from pathlib import Path + +import pytest +from uipath.runtime import UiPathRuntimeContext + +from uipath_pydantic_ai.runtime.errors import ( + UiPathPydanticAIErrorCode, + UiPathPydanticAIRuntimeError, +) +from uipath_pydantic_ai.runtime.factory import UiPathPydanticAIRuntimeFactory +from uipath_pydantic_ai.runtime.runtime import UiPathPydanticAIRuntime + + +def _project(tmp_path: Path, agents: dict[str, str]) -> None: + """Write a pydantic_ai.json plus a main.py exposing a TestModel agent.""" + (tmp_path / "pydantic_ai.json").write_text(json.dumps({"agents": agents})) + (tmp_path / "main.py").write_text( + "from pydantic_ai import Agent\n" + "from pydantic_ai.models.test import TestModel\n" + "agent = Agent(TestModel(), name='factory_agent')\n" + ) + + +def _factory() -> UiPathPydanticAIRuntimeFactory: + return UiPathPydanticAIRuntimeFactory(context=UiPathRuntimeContext()) + + +def test_discover_entrypoints_empty_without_config(tmp_path, monkeypatch): + """With no pydantic_ai.json present, no entrypoints are discovered.""" + monkeypatch.chdir(tmp_path) + assert _factory().discover_entrypoints() == [] + + +def test_discover_entrypoints_lists_configured_agents(tmp_path, monkeypatch): + """Configured agent names are returned as entrypoints.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + assert _factory().discover_entrypoints() == ["agent"] + + +@pytest.mark.asyncio +async def test_load_agent_config_missing_raises(tmp_path, monkeypatch): + """Loading an agent without a config file raises CONFIG_MISSING.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await _factory()._load_agent("agent") + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.CONFIG_MISSING.value + ) + + +@pytest.mark.asyncio +async def test_load_agent_unknown_entrypoint_raises(tmp_path, monkeypatch): + """Requesting an entrypoint absent from config raises AGENT_NOT_FOUND.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await _factory()._load_agent("nope") + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_NOT_FOUND.value + ) + + +@pytest.mark.asyncio +async def test_resolve_agent_caches_result(tmp_path, monkeypatch): + """Resolving the same entrypoint twice returns the cached Agent instance.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + factory = _factory() + + first = await factory._resolve_agent("agent") + second = await factory._resolve_agent("agent") + assert first is second + + +@pytest.mark.asyncio +async def test_new_runtime_builds_runtime_for_agent(tmp_path, monkeypatch): + """new_runtime returns a runtime bound to the resolved agent.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + factory = _factory() + + runtime = await factory.new_runtime("agent", runtime_id="rt-1") + assert isinstance(runtime, UiPathPydanticAIRuntime) + + +@pytest.mark.asyncio +async def test_discover_runtimes_one_per_entrypoint(tmp_path, monkeypatch): + """discover_runtimes yields a runtime for each configured entrypoint.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + runtimes = await _factory().discover_runtimes() + assert len(runtimes) == 1 + assert isinstance(runtimes[0], UiPathPydanticAIRuntime) + + +@pytest.mark.asyncio +async def test_dispose_clears_caches_and_loaders(tmp_path, monkeypatch): + """dispose() cleans up loaders and empties the agent cache.""" + monkeypatch.chdir(tmp_path) + _project(tmp_path, {"agent": "main.py:agent"}) + factory = _factory() + await factory._resolve_agent("agent") + assert factory._agent_cache + + await factory.dispose() + assert factory._agent_cache == {} + assert factory._agent_loaders == {} + + +@pytest.mark.asyncio +async def test_factory_storage_and_settings_are_none(tmp_path, monkeypatch): + """The factory advertises no shared storage or settings.""" + monkeypatch.chdir(tmp_path) + factory = _factory() + assert await factory.get_storage() is None + assert await factory.get_settings() is None diff --git a/packages/uipath-pydantic-ai/tests/test_loader.py b/packages/uipath-pydantic-ai/tests/test_loader.py new file mode 100644 index 00000000..397a0222 --- /dev/null +++ b/packages/uipath-pydantic-ai/tests/test_loader.py @@ -0,0 +1,187 @@ +"""Tests for the PydanticAI agent loader.""" + +import os +from pathlib import Path + +import pytest +from pydantic_ai import Agent + +from uipath_pydantic_ai.runtime.errors import ( + UiPathPydanticAIErrorCode, + UiPathPydanticAIRuntimeError, +) +from uipath_pydantic_ai.runtime.loader import PydanticAiAgentLoader + + +def _write_agent_file(directory: str, body: str, filename: str = "main.py") -> str: + path = os.path.join(directory, filename) + Path(path).write_text(body) + return path + + +# ============= PATH PARSING ============= + + +def test_from_path_string_parses_file_and_variable(): + """A valid 'file:variable' string is split into its parts.""" + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:my_agent") + assert loader.file_path == "main.py" + assert loader.variable_name == "my_agent" + + +def test_from_path_string_rejects_missing_colon(): + """A path without a colon raises a CONFIG_INVALID error.""" + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + PydanticAiAgentLoader.from_path_string("agent", "main.py") + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.CONFIG_INVALID.value + ) + + +# ============= LOAD SUCCESS PATHS ============= + + +@pytest.mark.asyncio +async def test_load_direct_agent_instance(tmp_path, monkeypatch): + """A module exposing a plain Agent instance loads successfully.""" + monkeypatch.chdir(tmp_path) + _write_agent_file( + str(tmp_path), + "from pydantic_ai import Agent\n" + "from pydantic_ai.models.test import TestModel\n" + "agent = Agent(TestModel(), name='direct')\n", + ) + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:agent") + agent = await loader.load() + assert isinstance(agent, Agent) + + +@pytest.mark.asyncio +async def test_load_factory_function(tmp_path, monkeypatch): + """A module exposing a sync factory function is called to produce the Agent.""" + monkeypatch.chdir(tmp_path) + _write_agent_file( + str(tmp_path), + "from pydantic_ai import Agent\n" + "from pydantic_ai.models.test import TestModel\n" + "def build():\n" + " return Agent(TestModel(), name='factory')\n", + ) + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:build") + agent = await loader.load() + assert isinstance(agent, Agent) + + +@pytest.mark.asyncio +async def test_load_async_factory_function(tmp_path, monkeypatch): + """A module exposing an async factory coroutine is awaited to produce the Agent.""" + monkeypatch.chdir(tmp_path) + _write_agent_file( + str(tmp_path), + "from pydantic_ai import Agent\n" + "from pydantic_ai.models.test import TestModel\n" + "async def build():\n" + " return Agent(TestModel(), name='async_factory')\n", + ) + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:build") + agent = await loader.load() + assert isinstance(agent, Agent) + + +@pytest.mark.asyncio +async def test_load_async_context_manager_and_cleanup(tmp_path, monkeypatch): + """An async-context-manager object is entered on load and exited on cleanup.""" + monkeypatch.chdir(tmp_path) + _write_agent_file( + str(tmp_path), + "from pydantic_ai import Agent\n" + "from pydantic_ai.models.test import TestModel\n" + "class Managed:\n" + " exited = False\n" + " async def __aenter__(self):\n" + " return Agent(TestModel(), name='ctx')\n" + " async def __aexit__(self, *a):\n" + " type(self).exited = True\n" + "cm = Managed()\n", + ) + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:cm") + agent = await loader.load() + assert isinstance(agent, Agent) + # cleanup should invoke __aexit__ on the stored context manager + assert loader._context_manager is not None + await loader.cleanup() + assert loader._context_manager is None + + +# ============= LOAD ERROR PATHS ============= + + +@pytest.mark.asyncio +async def test_load_rejects_path_outside_cwd(tmp_path, monkeypatch): + """A file path that escapes the working directory raises AGENT_VALUE_ERROR.""" + monkeypatch.chdir(tmp_path) + loader = PydanticAiAgentLoader.from_path_string("agent", "../outside.py:agent") + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_VALUE_ERROR.value + ) + + +@pytest.mark.asyncio +async def test_load_missing_file(tmp_path, monkeypatch): + """A non-existent agent file raises AGENT_NOT_FOUND.""" + monkeypatch.chdir(tmp_path) + loader = PydanticAiAgentLoader.from_path_string("agent", "missing.py:agent") + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_NOT_FOUND.value + ) + + +@pytest.mark.asyncio +async def test_load_missing_variable(tmp_path, monkeypatch): + """A module that lacks the requested variable raises AGENT_NOT_FOUND.""" + monkeypatch.chdir(tmp_path) + _write_agent_file(str(tmp_path), "x = 1\n") + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:agent") + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_NOT_FOUND.value + ) + + +@pytest.mark.asyncio +async def test_load_wrong_type(tmp_path, monkeypatch): + """A variable that is not a pydantic_ai.Agent raises AGENT_TYPE_ERROR.""" + monkeypatch.chdir(tmp_path) + _write_agent_file(str(tmp_path), "agent = 'not an agent'\n") + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:agent") + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_TYPE_ERROR.value + ) + + +@pytest.mark.asyncio +async def test_load_module_execution_error(tmp_path, monkeypatch): + """A module that raises at import time surfaces AGENT_LOAD_FAILURE.""" + monkeypatch.chdir(tmp_path) + _write_agent_file(str(tmp_path), "raise RuntimeError('boom')\n") + loader = PydanticAiAgentLoader.from_path_string("agent", "main.py:agent") + with pytest.raises(UiPathPydanticAIRuntimeError) as exc: + await loader.load() + assert exc.value.error_info.code.endswith( + UiPathPydanticAIErrorCode.AGENT_LOAD_FAILURE.value + ) + + +@pytest.mark.asyncio +async def test_cleanup_without_context_manager_is_noop(): + """cleanup() is safe to call when no context manager was entered.""" + loader = PydanticAiAgentLoader("agent", "main.py", "agent") + await loader.cleanup() # should not raise + assert loader._context_manager is None