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()