diff --git a/pyproject.toml b/pyproject.toml index 56f6bf8d9..e73e83ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.4.23" +version = "2.5" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.1.4, <0.2.0", - "uipath-runtime>=0.4.1, <0.5.0", + "uipath-runtime>=0.5, <0.6.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/src/uipath/_cli/_chat/_bridge.py b/src/uipath/_cli/_chat/_bridge.py index 74cea00df..c3f243cf3 100644 --- a/src/uipath/_cli/_chat/_bridge.py +++ b/src/uipath/_cli/_chat/_bridge.py @@ -1,6 +1,7 @@ """Chat bridge implementations for conversational agents.""" import asyncio +import json import logging import os import uuid @@ -55,6 +56,10 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() + # Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from + # interrupting the debugging session. Events will be logged instead of being sent. + self._websocket_disabled = os.environ.get("CAS_WEBSOCKET_DISABLED") == "true" + async def connect(self, timeout: float = 10.0) -> None: """Establish WebSocket connection to the server. @@ -87,37 +92,43 @@ async def connect(self, timeout: float = 10.0) -> None: self._client.on("connect", self._handle_connect) self._client.on("disconnect", self._handle_disconnect) self._client.on("connect_error", self._handle_connect_error) + self._client.on("ConversationEvent", self._handle_conversation_event) self._connected_event.clear() - try: - # Attempt to connect with timeout - await asyncio.wait_for( - self._client.connect( - url=self.websocket_url, - socketio_path=self.websocket_path, - headers=self.headers, - auth=self.auth, - transports=["websocket"], - ), - timeout=timeout, + if self._websocket_disabled: + logger.warning( + "SocketIOChatBridge is in debug mode. Not connecting websocket." ) + else: + try: + # Attempt to connect with timeout + await asyncio.wait_for( + self._client.connect( + url=self.websocket_url, + socketio_path=self.websocket_path, + headers=self.headers, + auth=self.auth, + transports=["websocket"], + ), + timeout=timeout, + ) - await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) + await asyncio.wait_for(self._connected_event.wait(), timeout=timeout) - except asyncio.TimeoutError as e: - error_message = ( - f"Failed to connect to WebSocket server within {timeout}s timeout" - ) - logger.error(error_message) - await self._cleanup_client() - raise RuntimeError(error_message) from e + except asyncio.TimeoutError as e: + error_message = ( + f"Failed to connect to WebSocket server within {timeout}s timeout" + ) + logger.error(error_message) + await self._cleanup_client() + raise RuntimeError(error_message) from e - except Exception as e: - error_message = f"Failed to connect to WebSocket server: {e}" - logger.error(error_message) - await self._cleanup_client() - raise RuntimeError(error_message) from e + except Exception as e: + error_message = f"Failed to connect to WebSocket server: {e}" + logger.error(error_message) + await self._cleanup_client() + raise RuntimeError(error_message) from e async def disconnect(self) -> None: """Close the WebSocket connection gracefully. @@ -150,7 +161,7 @@ async def emit_message_event( if self._client is None: raise RuntimeError("WebSocket client not connected. Call connect() first.") - if not self._connected_event.is_set(): + if not self._connected_event.is_set() and not self._websocket_disabled: raise RuntimeError("WebSocket client not in connected state") try: @@ -167,7 +178,12 @@ async def emit_message_event( mode="json", exclude_none=True, by_alias=True ) - await self._client.emit("ConversationEvent", event_data) + if self._websocket_disabled: + logger.info( + f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" + ) + else: + await self._client.emit("ConversationEvent", event_data) # Store the current message ID, used for emitting interrupt events. self._current_message_id = message_event.message_id @@ -185,7 +201,7 @@ async def emit_exchange_end_event(self) -> None: if self._client is None: raise RuntimeError("WebSocket client not connected. Call connect() first.") - if not self._connected_event.is_set(): + if not self._connected_event.is_set() and not self._websocket_disabled: raise RuntimeError("WebSocket client not in connected state") try: @@ -201,7 +217,12 @@ async def emit_exchange_end_event(self) -> None: mode="json", exclude_none=True, by_alias=True ) - await self._client.emit("ConversationEvent", event_data) + if self._websocket_disabled: + logger.info( + f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" + ) + else: + await self._client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending conversation event to WebSocket: {e}") @@ -231,7 +252,12 @@ async def emit_interrupt_event(self, runtime_result: UiPathRuntimeResult): event_data = interrupt_event.model_dump( mode="json", exclude_none=True, by_alias=True ) - await self._client.emit("ConversationEvent", event_data) + if self._websocket_disabled: + logger.info( + f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" + ) + else: + await self._client.emit("ConversationEvent", event_data) except Exception as e: logger.warning(f"Error sending interrupt event: {e}") @@ -266,6 +292,14 @@ async def _handle_connect_error(self, data: Any) -> None: """Handle connection error event.""" logger.error(f"WebSocket connection error: {data}") + async def _handle_conversation_event( + self, event: dict[str, Any], _sid: str + ) -> None: + """Handle received ConversationEvent events.""" + error_event = event.get("conversationError") + if error_event: + logger.error(f"Conversation error: {json.dumps(error_event)}") + async def _cleanup_client(self) -> None: """Clean up client resources.""" self._connected_event.clear() @@ -316,6 +350,13 @@ def get_chat_bridge( websocket_url = f"wss://{host}?conversationId={context.conversation_id}" websocket_path = "autopilotforeveryone_/websocket_/socket.io" + if os.environ.get("CAS_WEBSOCKET_HOST"): + websocket_url = f"ws://{os.environ.get('CAS_WEBSOCKET_HOST')}?conversationId={context.conversation_id}" + websocket_path = "/socket.io" + logger.warning( + f"CAS_WEBSOCKET_HOST is set. Using websocket_url '{websocket_url}{websocket_path}'." + ) + # Build headers from context headers = { "Authorization": f"Bearer {os.environ.get('UIPATH_ACCESS_TOKEN', '')}", diff --git a/src/uipath/_cli/cli_run.py b/src/uipath/_cli/cli_run.py index aadca9ffe..6f80b49bb 100644 --- a/src/uipath/_cli/cli_run.py +++ b/src/uipath/_cli/cli_run.py @@ -69,6 +69,11 @@ default=5678, help="Port for the debug server (default: 5678)", ) +@click.option( + "--keep-state-file", + is_flag=True, + help="Keep the state file even when not resuming and no job id is provided", +) def run( entrypoint: str | None, input: str | None, @@ -79,6 +84,7 @@ def run( trace_file: str | None, debug: bool, debug_port: int, + keep_state_file: bool, ) -> None: """Execute the project.""" input_file = file or input_file @@ -147,6 +153,7 @@ async def execute() -> None: resume=resume, command="run", trace_manager=trace_manager, + keep_state_file=keep_state_file, ) if ctx.trace_file: diff --git a/src/uipath/agent/models/agent.py b/src/uipath/agent/models/agent.py index 82c56e762..5222c0510 100644 --- a/src/uipath/agent/models/agent.py +++ b/src/uipath/agent/models/agent.py @@ -803,6 +803,15 @@ class AgentDefinition(BaseModel): validate_by_name=True, validate_by_alias=True, extra="allow" ) + @property + def is_conversational(self) -> bool: + """Checks the settings.engine property to determine if the agent is conversational.""" + if hasattr(self, "metadata") and self.metadata: + metadata = self.metadata + if hasattr(metadata, "is_conversational"): + return metadata.is_conversational + return False + @staticmethod def _normalize_guardrails(v: Dict[str, Any]) -> None: guards = v.get("guardrails") diff --git a/src/uipath/agent/react/__init__.py b/src/uipath/agent/react/__init__.py index e3f4db781..e37b5c725 100644 --- a/src/uipath/agent/react/__init__.py +++ b/src/uipath/agent/react/__init__.py @@ -3,6 +3,10 @@ This module includes UiPath ReAct Agent Loop constructs such as prompts, tools """ +from .conversational_prompts import ( + PromptUserSettings, + get_chat_system_prompt, +) from .prompts import AGENT_SYSTEM_PROMPT_TEMPLATE from .tools import ( END_EXECUTION_TOOL, @@ -19,4 +23,6 @@ "RAISE_ERROR_TOOL", "EndExecutionToolSchemaModel", "RaiseErrorToolSchemaModel", + "PromptUserSettings", + "get_chat_system_prompt", ] diff --git a/src/uipath/agent/react/conversational_prompts.py b/src/uipath/agent/react/conversational_prompts.py new file mode 100644 index 000000000..4c4211140 --- /dev/null +++ b/src/uipath/agent/react/conversational_prompts.py @@ -0,0 +1,302 @@ +"""Conversational agent prompt generation logic.""" + +import json +import logging +from datetime import datetime, timezone +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from uipath.agent.models.agent import AgentMessage + +logger = logging.getLogger(__name__) + + +class CitationType(Enum): + """Citation type for system prompt generation. + + Some models may have issues wrapping citation tags around text. + In those cases, we can prompt the citation tags to be placed after the text instead. + We also allow disabling citations entirely, for scenarios such as voice output. + """ + + NONE = "none" + WRAPPED = "wrapped" + TRAILING = "trailing" + + +class PromptUserSettings(BaseModel): + """User settings for inclusion in the system prompt.""" + + name: Optional[str] = None + email: Optional[str] = None + role: Optional[str] = None + department: Optional[str] = None + company: Optional[str] = None + country: Optional[str] = None + timezone: Optional[str] = None + + +_AGENT_SYSTEM_PROMPT_PREFIX_TEMPLATE = """You are {{CONVERSATIONAL_AGENT_SERVICE_PREFIX_agentName}}. +The current date is: {{CONVERSATIONAL_AGENT_SERVICE_PREFIX_currentDate}}. +Understand user goals through conversation and use appropriate tools to fulfill requests. + +===================================================================== +PRECEDENCE HIERARCHY +===================================================================== +1. Core System Instructions (highest authority) +2. Agent System Prompt +3. Tool definitions and parameter schemas +4. User instructions and follow-up messages + +When conflicts occur, follow the highest-precedence rule above. + +===================================================================== +AGENT SYSTEM PROMPT +===================================================================== +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_systemPrompt}} + +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_attachmentsPrompt}} + +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_userSettingsPrompt}} + +===================================================================== +TOOL USAGE RULES +===================================================================== +Parameter Resolution Priority: +1. Check tool definitions for pre-configured values +2. Use conversation context +3. Ask user only if unavailable + +Execution: +- Use tools ONLY with complete, specific data for all required parameters +- NEVER use placeholders or incomplete information +- Call independent tools in parallel when possible + +On Missing Data: +- Ask user for specifics before proceeding +- Never attempt calls with incomplete data +- On errors: modify parameters or change approach (never retry identical calls) + +===================================================================== +TOOL RESULTS +===================================================================== +Tool results contain: +- status: "success" or "error" +- data: result payload or exception details + +Rules: +- For "success": check data for actual results +- For "error": summarize issue and adjust approach + +===================================================================== +CITATION RULES +===================================================================== +Citations will be parsed into the user interface. + +WHAT TO CITE: +- Any information drawn from web search results. +- Any information drawn from Context Grounding documents. + +CITATION FORMAT: +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_citationFormatPrompt}} + +TOOL RESULT PATTERNS REQUIRING CITATION: +Tool results containing these fields indicate citable sources: +- Web results: "url", "title" fields +- Context Grounding: objects with "reference", "source", "page_number", "content" + +SOURCE FORMATS: +- URLs: {"title":"Page Title","url":"https://example.com"} +- Context Grounding: {"title":"filename.pdf","reference":"https://ref.url","page_number":1} + where title is set to the document source (filename), and reference and page_number + are from the tool results + +RULES: +- Minimum 1 source per citation (never empty array) +- Truncate titles >48 chars +- Never include citations in tool inputs + +{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_citationExamplePrompt}} + +===================================================================== +EXECUTION CHECKLIST +===================================================================== +Before each tool call, verify: +1. Pre-configured values have been checked +2. All parameters are complete and specific + +If execution cannot proceed: +- State why +- Request missing or clarifying information""" + +_ATTACHMENTS_TEMPLATE = """===================================================================== +ATTACHMENTS +===================================================================== +- You are capable of working with job attachments. Job attachments are file references. +- If the user has attached files, they will be in the format of [...] in the user message. Example: [{"ID":"123","Type":"JobAttachment","FullName":"example.json","MimeType":"application/json","Metadata":{"key1":"value1","key2":"value2"}}] +- You must send only the JobAttachment ID as the parameter values to a tool that accepts job attachments. +- If the attachment ID is passed and not found, suggest the user to upload the file again.""" + +_USER_CONTEXT_TEMPLATE = """===================================================================== +USER CONTEXT +===================================================================== +You have the following information about the user: +```json +{user_settings_json} +```""" + +_CITATION_FORMAT_WRAPPED = "factual claim here" +_CITATION_FORMAT_TRAILING = "factual claim here" + +_CITATION_EXAMPLE_WRAPPED = """EXAMPLES OF CORRECT USAGE: +AI adoption is growing + +CRITICAL ERRORS TO AVOID: +text (empty sources) +Some textpartmore text (spacing) + (empty claim)""" + +_CITATION_EXAMPLE_TRAILING = """EXAMPLES OF CORRECT USAGE: +AI adoption is growing + +CRITICAL ERRORS TO AVOID: +text (empty sources) +Some textpartmore text (content between citation tags)""" + + +def get_chat_system_prompt( + model: str, + messages: list[AgentMessage], + name: str | None, + user_settings: Optional[PromptUserSettings], +) -> str: + """Generate a system prompt for a conversational agent. + + Args: + agent_definition: Conversational agent definition + user_settings: Optional user data that is injected into the system prompt. + + Returns: + The complete system prompt string + """ + system_message = next((msg for msg in messages if msg.role == "system"), None) + if system_message is None: + raise ValueError( + "Conversational agent configuration must contain exactly one system message" + ) + + # Determine citation type based on model + citation_type = _get_citation_type(model) + + # Format date as ISO 8601 (yyyy-MM-ddTHH:mmZ) + formatted_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ") + + prompt = _AGENT_SYSTEM_PROMPT_PREFIX_TEMPLATE + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_agentName}}", + name or "Unnamed Agent", + ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_currentDate}}", + formatted_date, + ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_systemPrompt}}", + system_message.content, + ) + # Always include attachments prompt + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_attachmentsPrompt}}", + _ATTACHMENTS_TEMPLATE, + ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_userSettingsPrompt}}", + _get_user_settings_template(user_settings), + ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_citationFormatPrompt}}", + _get_citation_format_prompt(citation_type), + ) + prompt = prompt.replace( + "{{CONVERSATIONAL_AGENT_SERVICE_PREFIX_citationExamplePrompt}}", + _get_citation_example_prompt(citation_type), + ) + + return prompt + + +def _get_citation_type(model: str) -> CitationType: + """Determine the citation type based on the agent's model. + + GPT models use trailing citations due to issues with generating + wrapped citations around text. + + Args: + model: The model name + + Returns: + CitationType.TRAILING for GPT models, CitationType.WRAPPED otherwise + """ + if "gpt" in model.lower(): + return CitationType.TRAILING + return CitationType.WRAPPED + + +def _get_user_settings_template( + user_settings: Optional[PromptUserSettings], +) -> str: + """Get the user settings template section. + + Args: + user_settings: User profile information + + Returns: + The user context template with JSON or empty string + """ + if user_settings is None: + return "" + + # Convert to dict, filtering out None values + settings_dict = { + k: v for k, v in user_settings.model_dump().items() if v is not None + } + + if not settings_dict: + return "" + + user_settings_json = json.dumps(settings_dict, ensure_ascii=False) + return _USER_CONTEXT_TEMPLATE.format(user_settings_json=user_settings_json) + + +def _get_citation_format_prompt(citation_type: CitationType) -> str: + """Get the citation format prompt based on citation type. + + Args: + citation_type: The type of citation formatting to use + + Returns: + The citation format string or empty string for NONE + """ + if citation_type == CitationType.WRAPPED: + return _CITATION_FORMAT_WRAPPED + elif citation_type == CitationType.TRAILING: + return _CITATION_FORMAT_TRAILING + return "" + + +def _get_citation_example_prompt(citation_type: CitationType) -> str: + """Get the citation example prompt based on citation type. + + Args: + citation_type: The type of citation formatting to use + + Returns: + The citation examples string or empty string for NONE + """ + if citation_type == CitationType.WRAPPED: + return _CITATION_EXAMPLE_WRAPPED + elif citation_type == CitationType.TRAILING: + return _CITATION_EXAMPLE_TRAILING + return "" diff --git a/src/uipath/agent/react/prompts.py b/src/uipath/agent/react/prompts.py index 0e806b64c..81ee4e3f6 100644 --- a/src/uipath/agent/react/prompts.py +++ b/src/uipath/agent/react/prompts.py @@ -8,7 +8,7 @@ {{systemPrompt}} -Your adhere strictly to the following rules to ensure accuracy and data validity: +You adhere strictly to the following rules to ensure accuracy and data validity: Data Verification and Tool Analysis: diff --git a/tests/agent/models/test_agent.py b/tests/agent/models/test_agent.py index 124b72ccb..390bf8d9f 100644 --- a/tests/agent/models/test_agent.py +++ b/tests/agent/models/test_agent.py @@ -2280,3 +2280,109 @@ def test_direct_recipient_instantiation( assert isinstance(recipient, recipient_class) assert recipient.type == expected_type + + +class TestAgentDefinitionIsConversational: + """Tests for AgentDefinition.is_conversational property.""" + + def test_is_conversational_true_when_metadata_set(self): + """Returns True when metadata.is_conversational is True.""" + json_data = { + "id": "test-conversational", + "name": "Conversational Agent", + "version": "1.0.0", + "metadata": {"isConversational": True, "storageVersion": "1.0.0"}, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0.7, + "engine": "conversational-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [ + {"role": "system", "content": "You are a conversational agent."} + ], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_conversational is True + + def test_is_conversational_false_when_metadata_set_false(self): + """Returns False when metadata.is_conversational is False.""" + json_data = { + "id": "test-non-conversational", + "name": "Non-Conversational Agent", + "version": "1.0.0", + "metadata": {"isConversational": False, "storageVersion": "1.0.0"}, + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0.7, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_conversational is False + + def test_is_conversational_false_when_no_metadata(self): + """Returns False when agent has no metadata.""" + json_data = { + "id": "test-no-metadata", + "name": "Agent Without Metadata", + "version": "1.0.0", + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0.7, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "You are an agent."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.metadata is None + assert config.is_conversational is False + + def test_is_conversational_false_by_default(self): + """Default agent definition returns False.""" + # Minimal agent definition without conversational settings + json_data = { + "id": "test-default", + "name": "Default Agent", + "version": "1.0.0", + "settings": { + "model": "gpt-4o", + "maxTokens": 4096, + "temperature": 0, + "engine": "basic-v1", + }, + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": {"type": "object", "properties": {}}, + "resources": [], + "messages": [{"role": "system", "content": "Default system prompt."}], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.is_conversational is False diff --git a/tests/agent/react/__init__.py b/tests/agent/react/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/agent/react/test_conversational_prompts.py b/tests/agent/react/test_conversational_prompts.py new file mode 100644 index 000000000..c7d0e8610 --- /dev/null +++ b/tests/agent/react/test_conversational_prompts.py @@ -0,0 +1,441 @@ +"""Tests for conversational agent prompt generation.""" + +import json +import re +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest + +from uipath.agent.models.agent import AgentMessage, AgentMessageRole +from uipath.agent.react.conversational_prompts import ( + CitationType, + PromptUserSettings, + _get_citation_example_prompt, + _get_citation_format_prompt, + _get_citation_type, + _get_user_settings_template, + get_chat_system_prompt, +) + + +def _create_messages( + system_message: str = "You are a helpful assistant.", + include_system_message: bool = True, +) -> list[AgentMessage]: + """Helper to create a list of messages for testing.""" + messages = [] + if include_system_message: + messages.append( + AgentMessage(role=AgentMessageRole.SYSTEM, content=system_message) + ) + messages.append(AgentMessage(role=AgentMessageRole.USER, content="Hello")) + return messages + + +class TestGenerateConversationalAgentSystemPrompt: + """Tests for get_chat_system_prompt function.""" + + def test_generate_system_prompt_basic(self): + """Generate prompt with minimal inputs.""" + messages = _create_messages(system_message="You are a basic assistant.") + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Basic Agent", + user_settings=None, + ) + + assert "You are Basic Agent." in prompt + assert "You are a basic assistant." in prompt + assert "AGENT SYSTEM PROMPT" in prompt + assert "TOOL USAGE RULES" in prompt + + def test_generate_system_prompt_with_user_settings(self): + """Prompt includes user context when PromptUserSettings provided.""" + messages = _create_messages() + user_settings = PromptUserSettings( + name="John Doe", + email="john.doe@example.com", + role="Developer", + department="Engineering", + company="Acme Corp", + country="USA", + timezone="America/New_York", + ) + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=user_settings, + ) + + assert "USER CONTEXT" in prompt + assert "John Doe" in prompt + assert "john.doe@example.com" in prompt + assert "Developer" in prompt + assert "Engineering" in prompt + assert "Acme Corp" in prompt + assert "USA" in prompt + assert "America/New_York" in prompt + + def test_generate_system_prompt_without_user_settings(self): + """Prompt excludes user context section when user_settings=None.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "USER CONTEXT" not in prompt + + def test_generate_system_prompt_with_partial_user_settings(self): + """Only non-None user settings fields are included in JSON.""" + messages = _create_messages() + user_settings = PromptUserSettings( + name="Jane Doe", + email="jane@example.com", + # Other fields are None + ) + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=user_settings, + ) + + assert "USER CONTEXT" in prompt + assert "Jane Doe" in prompt + assert "jane@example.com" in prompt + # None fields should not appear in the JSON + assert '"role":' not in prompt + assert '"department":' not in prompt + + def test_generate_system_prompt_missing_system_message_raises(self): + """Raises ValueError when messages have no system message.""" + messages = _create_messages(include_system_message=False) + + with pytest.raises(ValueError) as exc_info: + get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "exactly one system message" in str(exc_info.value) + + def test_generate_system_prompt_includes_agent_name(self): + """Agent name is substituted into prompt.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Customer Support Bot", + user_settings=None, + ) + + assert "You are Customer Support Bot." in prompt + + def test_generate_system_prompt_includes_current_date(self): + """Current date in ISO 8601 format is included.""" + messages = _create_messages() + + # Mock datetime to have a predictable value + mock_dt = datetime(2026, 1, 15, 10, 30, tzinfo=timezone.utc) + with patch( + "uipath.agent.react.conversational_prompts.datetime" + ) as mock_datetime: + mock_datetime.now.return_value = mock_dt + mock_datetime.side_effect = lambda *args, **kwargs: datetime( + *args, **kwargs + ) + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "2026-01-15T10:30Z" in prompt + + def test_generate_system_prompt_includes_attachments_section(self): + """Attachments template is always included.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "ATTACHMENTS" in prompt + assert "job attachments" in prompt.lower() + assert "" in prompt + + def test_generate_system_prompt_unnamed_agent_uses_default(self): + """Unnamed agent defaults to 'Unnamed Agent'.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name=None, + user_settings=None, + ) + + assert "You are Unnamed Agent." in prompt + + +class TestCitationType: + """Tests for citation type determination.""" + + @pytest.mark.parametrize( + "model", + [ + "claude-3-sonnet", + "claude-3-opus", + "claude-3-haiku", + "gemini-pro", + "llama-3", + "mistral-large", + ], + ) + def test_citation_type_wrapped_for_non_gpt_models(self, model): + """Non-GPT models get CitationType.WRAPPED.""" + citation_type = _get_citation_type(model) + + assert citation_type == CitationType.WRAPPED + + @pytest.mark.parametrize( + "model", + [ + "gpt-4", + "gpt-4o", + "gpt-4o-2024-11-20", + "gpt-3.5-turbo", + "GPT-4", # Test case insensitivity + "GPT-4O-MINI", + ], + ) + def test_citation_type_trailing_for_gpt_models(self, model): + """GPT models get CitationType.TRAILING.""" + citation_type = _get_citation_type(model) + + assert citation_type == CitationType.TRAILING + + +class TestCitationFormatPrompt: + """Tests for citation format in generated prompts.""" + + def test_citation_format_wrapped_in_prompt(self): + """Wrapped citation format appears in prompt for non-GPT models.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "factual claim here" in prompt + + def test_citation_format_trailing_in_prompt(self): + """Trailing citation format appears in prompt for GPT models.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="gpt-4o", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + assert "factual claim here" in prompt + + def test_wrapped_citation_examples_in_prompt(self): + """Wrapped citation examples appear for non-GPT models.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="claude-3-sonnet", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + # Check wrapped example + assert ( + 'AI adoption is growing' + in prompt + ) + # Should NOT contain trailing example pattern + assert ( + 'AI adoption is growing' + not in prompt + ) + + def test_trailing_citation_examples_in_prompt(self): + """Trailing citation examples appear for GPT models.""" + messages = _create_messages() + + prompt = get_chat_system_prompt( + model="gpt-4o", + messages=messages, + name="Test Agent", + user_settings=None, + ) + + # Check trailing example + assert ( + 'AI adoption is growing' + in prompt + ) + + +class TestGetCitationFormatPrompt: + """Tests for _get_citation_format_prompt helper.""" + + def test_wrapped_format(self): + """Returns wrapped format string.""" + result = _get_citation_format_prompt(CitationType.WRAPPED) + assert "factual claim here" in result + + def test_trailing_format(self): + """Returns trailing format string.""" + result = _get_citation_format_prompt(CitationType.TRAILING) + assert "factual claim here" in result + + def test_none_format(self): + """Returns empty string for NONE type.""" + result = _get_citation_format_prompt(CitationType.NONE) + assert result == "" + + +class TestGetCitationExamplePrompt: + """Tests for _get_citation_example_prompt helper.""" + + def test_wrapped_example(self): + """Returns wrapped example string.""" + result = _get_citation_example_prompt(CitationType.WRAPPED) + assert "EXAMPLES OF CORRECT USAGE:" in result + assert "CRITICAL ERRORS TO AVOID:" in result + assert ( + 'AI adoption is growing' + in result + ) + + def test_trailing_example(self): + """Returns trailing example string.""" + result = _get_citation_example_prompt(CitationType.TRAILING) + assert "EXAMPLES OF CORRECT USAGE:" in result + assert "CRITICAL ERRORS TO AVOID:" in result + assert ( + 'AI adoption is growing' + in result + ) + + def test_none_example(self): + """Returns empty string for NONE type.""" + result = _get_citation_example_prompt(CitationType.NONE) + assert result == "" + + +class TestPromptUserSettings: + """Tests for PromptUserSettings dataclass.""" + + def test_all_fields_populated(self): + """All fields populated in PromptUserSettings.""" + settings = PromptUserSettings( + name="Test User", + email="test@example.com", + role="Admin", + department="IT", + company="Test Co", + country="Canada", + timezone="America/Toronto", + ) + + assert settings.name == "Test User" + assert settings.email == "test@example.com" + assert settings.role == "Admin" + assert settings.department == "IT" + assert settings.company == "Test Co" + assert settings.country == "Canada" + assert settings.timezone == "America/Toronto" + + def test_default_none_values(self): + """Default values are None.""" + settings = PromptUserSettings() + + assert settings.name is None + assert settings.email is None + assert settings.role is None + assert settings.department is None + assert settings.company is None + assert settings.country is None + assert settings.timezone is None + + +class TestGetUserSettingsTemplate: + """Tests for _get_user_settings_template helper.""" + + def test_none_returns_empty(self): + """Returns empty string when user_settings is None.""" + result = _get_user_settings_template(None) + assert result == "" + + def test_empty_settings_returns_empty(self): + """Returns empty string when all fields are None.""" + settings = PromptUserSettings() + result = _get_user_settings_template(settings) + assert result == "" + + def test_partial_settings_includes_non_none_only(self): + """Only includes non-None fields in JSON.""" + settings = PromptUserSettings(name="Test", email="test@example.com") + result = _get_user_settings_template(settings) + + assert "USER CONTEXT" in result + assert '"name": "Test"' in result or '"name":"Test"' in result + assert "test@example.com" in result + # None fields should not be present + assert "role" not in result + assert "department" not in result + + def test_full_settings_json_format(self): + """Full settings are formatted as valid JSON.""" + settings = PromptUserSettings( + name="Full User", + email="full@example.com", + role="Manager", + department="Sales", + company="Big Corp", + country="UK", + timezone="Europe/London", + ) + result = _get_user_settings_template(settings) + + # Extract JSON from the result + json_match = re.search(r"```json\s*(\{[^`]+\})\s*```", result) + assert json_match is not None + + # Validate it's proper JSON + json_data = json.loads(json_match.group(1)) + assert json_data["name"] == "Full User" + assert json_data["email"] == "full@example.com" + assert json_data["role"] == "Manager" + assert json_data["department"] == "Sales" + assert json_data["company"] == "Big Corp" + assert json_data["country"] == "UK" + assert json_data["timezone"] == "Europe/London" diff --git a/tests/cli/chat/__init__.py b/tests/cli/chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/chat/test_bridge.py b/tests/cli/chat/test_bridge.py new file mode 100644 index 000000000..934558430 --- /dev/null +++ b/tests/cli/chat/test_bridge.py @@ -0,0 +1,306 @@ +"""Tests for SocketIOChatBridge and get_chat_bridge.""" + +import logging +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest + +from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge + + +class MockRuntimeContext: + """Mock UiPathRuntimeContext for testing.""" + + def __init__( + self, + conversation_id: str = "test-conversation-id", + exchange_id: str = "test-exchange-id", + tenant_id: str = "test-tenant-id", + org_id: str = "test-org-id", + ): + self.conversation_id = conversation_id + self.exchange_id = exchange_id + self.tenant_id = tenant_id + self.org_id = org_id + + +class TestSocketIOChatBridgeDebugMode: + """Tests for SocketIOChatBridge debug mode (CAS_WEBSOCKET_DISABLED).""" + + def test_websocket_disabled_flag_set_from_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """CAS_WEBSOCKET_DISABLED=true sets _websocket_disabled flag.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + assert bridge._websocket_disabled is True + + def test_websocket_disabled_flag_false_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """_websocket_disabled is False when env var not set.""" + monkeypatch.delenv("CAS_WEBSOCKET_DISABLED", raising=False) + + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + assert bridge._websocket_disabled is False + + def test_websocket_disabled_flag_false_when_not_true( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """_websocket_disabled is False when env var is not 'true'.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "false") + + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + assert bridge._websocket_disabled is False + + @pytest.mark.anyio + async def test_websocket_disabled_connect_logs_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """With CAS_WEBSOCKET_DISABLED=true, connect() logs warning but doesn't connect.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + with caplog.at_level(logging.WARNING): + await bridge.connect() + + assert "debug mode" in caplog.text.lower() + assert "not connecting" in caplog.text.lower() + # Client should be created but not connected + assert bridge._client is not None + assert not bridge._connected_event.is_set() + + +class TestGetChatBridgeCustomHost: + """Tests for get_chat_bridge with CAS_WEBSOCKET_HOST environment variable.""" + + def test_custom_websocket_host_env_var( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """CAS_WEBSOCKET_HOST overrides websocket URL to ws:// scheme.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("CAS_WEBSOCKET_HOST", "localhost:8080") + + context = MockRuntimeContext() + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "ws://localhost:8080" in bridge.websocket_url + assert "wss://" not in bridge.websocket_url + + def test_custom_websocket_host_uses_simple_path( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Custom host uses /socket.io path instead of full path.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.setenv("CAS_WEBSOCKET_HOST", "localhost:8080") + + context = MockRuntimeContext() + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.websocket_path == "/socket.io" + + def test_default_websocket_url_without_custom_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Default URL construction without CAS_WEBSOCKET_HOST.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.delenv("CAS_WEBSOCKET_HOST", raising=False) + + context = MockRuntimeContext(conversation_id="conv-abc") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "wss://cloud.uipath.com" in bridge.websocket_url + assert "conversationId=conv-abc" in bridge.websocket_url + assert bridge.websocket_path == "autopilotforeveryone_/websocket_/socket.io" + + def test_get_chat_bridge_includes_conversation_id_in_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation ID is included in websocket URL.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + monkeypatch.delenv("CAS_WEBSOCKET_HOST", raising=False) + + context = MockRuntimeContext(conversation_id="my-conversation-id") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "conversationId=my-conversation-id" in bridge.websocket_url + + +class TestGetChatBridge: + """Tests for get_chat_bridge factory function.""" + + def test_get_chat_bridge_returns_socket_io_bridge( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Returns SocketIOChatBridge instance.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + + context = MockRuntimeContext() + + bridge = get_chat_bridge(cast(Any, context)) + + assert isinstance(bridge, SocketIOChatBridge) + + def test_get_chat_bridge_constructs_correct_headers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Headers include Authorization and other required fields.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "my-access-token") + + context = MockRuntimeContext( + tenant_id="tenant-123", + org_id="org-456", + conversation_id="conv-789", + ) + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert "Authorization" in bridge.headers + assert "Bearer my-access-token" in bridge.headers["Authorization"] + assert "X-UiPath-Internal-TenantId" in bridge.headers + assert "X-UiPath-Internal-AccountId" in bridge.headers + assert "X-UiPath-ConversationId" in bridge.headers + assert bridge.headers["X-UiPath-ConversationId"] == "conv-789" + + def test_get_chat_bridge_raises_without_uipath_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Raises RuntimeError if UIPATH_URL is not set.""" + monkeypatch.delenv("UIPATH_URL", raising=False) + + context = MockRuntimeContext() + + with pytest.raises(RuntimeError) as exc_info: + get_chat_bridge(cast(Any, context)) + + assert "UIPATH_URL" in str(exc_info.value) + + def test_get_chat_bridge_raises_with_invalid_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Raises RuntimeError if UIPATH_URL is invalid.""" + monkeypatch.setenv("UIPATH_URL", "not-a-valid-url") + + context = MockRuntimeContext() + + with pytest.raises(RuntimeError) as exc_info: + get_chat_bridge(cast(Any, context)) + + assert "Invalid UIPATH_URL" in str(exc_info.value) + + def test_get_chat_bridge_sets_exchange_id( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Exchange ID from context is set on bridge.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + + context = MockRuntimeContext(exchange_id="my-exchange-id") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.exchange_id == "my-exchange-id" + + def test_get_chat_bridge_sets_conversation_id( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Conversation ID from context is set on bridge.""" + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/org/tenant") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + + context = MockRuntimeContext(conversation_id="my-conversation-id") + + bridge = cast(SocketIOChatBridge, get_chat_bridge(cast(Any, context))) + + assert bridge.conversation_id == "my-conversation-id" + + +class TestSocketIOChatBridgeConnectionStates: + """Tests for SocketIOChatBridge connection state handling.""" + + def test_is_connected_false_initially(self) -> None: + """is_connected is False before connecting.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + assert bridge.is_connected is False + + @pytest.mark.anyio + async def test_emit_message_raises_without_client(self) -> None: + """emit_message_event raises RuntimeError if client not initialized.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + mock_message_event = MagicMock() + mock_message_event.message_id = "msg-123" + + with pytest.raises(RuntimeError) as exc_info: + await bridge.emit_message_event(mock_message_event) + + assert "not connected" in str(exc_info.value).lower() + + @pytest.mark.anyio + async def test_emit_exchange_end_raises_without_client(self) -> None: + """emit_exchange_end_event raises RuntimeError if client not initialized.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + with pytest.raises(RuntimeError) as exc_info: + await bridge.emit_exchange_end_event() + + assert "not connected" in str(exc_info.value).lower()