Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions packages/uipath-agent-framework/tests/test_chat_clients.py
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 57 additions & 0 deletions packages/uipath-agent-framework/tests/test_chat_common.py
Original file line number Diff line number Diff line change
@@ -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")
40 changes: 40 additions & 0 deletions packages/uipath-agent-framework/tests/test_chat_module.py
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 37 additions & 0 deletions packages/uipath-agent-framework/tests/test_cli_new.py
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions packages/uipath-agent-framework/tests/test_loader.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading