diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 147726394..c07e4c3b7 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py index 77fa6cbee..ee4a4c674 100644 --- a/packages/uipath-core/src/uipath/core/chat/__init__.py +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -71,6 +71,7 @@ ) from .event import UiPathConversationEvent, UiPathConversationLabelUpdatedEvent from .exchange import ( + UiPathClientSideToolDeclaration, UiPathConversationExchange, UiPathConversationExchangeData, UiPathConversationExchangeEndEvent, @@ -107,6 +108,7 @@ UiPathSessionStartEvent, ) from .tool import ( + UiPathConversationExecutingToolCallEvent, UiPathConversationToolCall, UiPathConversationToolCallConfirmation, UiPathConversationToolCallConfirmationData, @@ -138,6 +140,7 @@ "UiPathSessionEndingEvent", "UiPathSessionEndEvent", # Exchange + "UiPathClientSideToolDeclaration", "UiPathConversationExchangeStartEvent", "UiPathConversationExchangeEndEvent", "UiPathConversationExchangeEvent", @@ -171,6 +174,7 @@ "UiPathConversationCitationData", "UiPathConversationCitation", # Tool + "UiPathConversationExecutingToolCallEvent", "UiPathConversationToolCallStartEvent", "UiPathConversationToolCallEndEvent", "UiPathConversationToolCallConfirmation", diff --git a/packages/uipath-core/src/uipath/core/chat/exchange.py b/packages/uipath-core/src/uipath/core/chat/exchange.py index 788bbe560..835489dfb 100644 --- a/packages/uipath-core/src/uipath/core/chat/exchange.py +++ b/packages/uipath-core/src/uipath/core/chat/exchange.py @@ -28,11 +28,24 @@ ) +class UiPathClientSideToolDeclaration(BaseModel): + """A client-side tool declaration from the SDK client.""" + + name: str + input_schema: dict[str, Any] | None = Field(None, alias="inputSchema") + output_schema: dict[str, Any] | None = Field(None, alias="outputSchema") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationExchangeStartEvent(BaseModel): """Signals the start of an exchange of messages within a conversation.""" conversation_sequence: int | None = Field(None, alias="conversationSequence") metadata: dict[str, Any] | None = Field(None, alias="metaData") + client_side_tools: list[UiPathClientSideToolDeclaration] | None = Field( + None, alias="clientSideTools" + ) timestamp: str | None = None model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py index 8af5fb604..514e42908 100644 --- a/packages/uipath-core/src/uipath/core/chat/tool.py +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -27,6 +27,8 @@ class UiPathConversationToolCallStartEvent(BaseModel): metadata: dict[str, Any] | None = Field(None, alias="metaData") require_confirmation: bool | None = Field(None, alias="requireConfirmation") input_schema: Any | None = Field(None, alias="inputSchema") + is_client_side_tool: bool | None = Field(None, alias="isClientSideTool") + output_schema: Any | None = Field(None, alias="outputSchema") model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) @@ -43,6 +45,19 @@ class UiPathConversationToolCallEndEvent(BaseModel): model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) +class UiPathConversationExecutingToolCallEvent(BaseModel): + """Signals the client that the tool is about to be executed. + + Emitted in all scenarios. For client-side tools, the client should begin + executing its handler upon receiving this event. + """ + + timestamp: str | None = None + input: dict[str, Any] | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + class UiPathConversationToolCallConfirmationEvent(BaseModel): """Signals a tool call confirmation (approve/reject) from the client.""" @@ -82,6 +97,9 @@ class UiPathConversationToolCallEvent(BaseModel): confirm: UiPathConversationToolCallConfirmationEvent | None = Field( None, alias="confirmToolCall" ) + executing: UiPathConversationExecutingToolCallEvent | None = Field( + None, alias="executingToolCall" + ) meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 9b043599c..9aa9417f4 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-20T15:11:06.1716446Z" +exclude-newer = "2026-05-25T19:32:11.835974Z" exclude-newer-span = "P2D" [[package]] @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index bee52f2aa..dabbd63ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2cfaef895..a7a978f7c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.10.72" +version = "2.10.73" 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" diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index 2a382a59e..96566e898 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -13,8 +13,11 @@ UiPathConversationEvent, UiPathConversationExchangeEndEvent, UiPathConversationExchangeEvent, + UiPathConversationExecutingToolCallEvent, UiPathConversationMessageEvent, UiPathConversationToolCallConfirmationEvent, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, ) from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.chat import UiPathChatProtocol @@ -122,9 +125,11 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() - self._tool_confirmation_event = asyncio.Event() - self._tool_confirmation_value: ( - UiPathConversationToolCallConfirmationEvent | None + self._tool_resume_event = asyncio.Event() + self._tool_resume_value: ( + UiPathConversationToolCallConfirmationEvent + | UiPathConversationToolCallEndEvent + | None ) = None self._current_message_id: str | None = None @@ -362,33 +367,52 @@ async def emit_exchange_error_event(self, error: Exception) -> None: async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): """No-op. - Tool confirmation — the only interrupt pattern CAS uses today — is - handled end-to-end via ``startToolCall`` with ``requireConfirmation: - true`` paired with ``wait_for_resume()``. This is deliberately - simpler than the old interrupt-based flow: CAS needs - ``requireConfirmation`` on the tool call event itself to render the - confirmation UI, so a parallel ``startInterrupt`` event would be - redundant. - - The only hypothetical reason to put work here is a generic, - non-tool-call agent interrupt (e.g. a coded agent calling - ``interrupt("do you want to continue?")``). Nothing uses that today - and it's not a near-term requirement — the method is kept for - generic flexibility. + Tool confirmation is handled end-to-end via ``startToolCall`` with + ``requireConfirmation: true`` paired with ``wait_for_resume()``. + executingToolCall is emitted by the MessageMapper (non-confirmed + tools) and the runtime loop post-confirmation (confirmed tools). """ return None + async def emit_executing_tool_call_event( + self, + tool_call_id: str, + tool_input: dict[str, Any] | None = None, + ) -> None: + """Emit an executingToolCall event. + + Called by the runtime loop after a tool-call confirmation resumes + to signal that the tool is about to execute with the final input. + """ + if not self._current_message_id: + return + + executing_event = UiPathConversationMessageEvent( + message_id=self._current_message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=tool_call_id, + executing=UiPathConversationExecutingToolCallEvent( + input=tool_input, + ), + ), + ) + await self.emit_message_event(executing_event) + async def wait_for_resume(self) -> dict[str, Any]: - """Wait for a confirmToolCall event to be received.""" - self._tool_confirmation_event.clear() - self._tool_confirmation_value = None + """Wait for a tool resume event (confirmToolCall or endToolCall) to be received.""" + if self._tool_resume_value is None: + self._tool_resume_event.clear() + await self._tool_resume_event.wait() - await self._tool_confirmation_event.wait() + value = self._tool_resume_value + self._tool_resume_value = None + self._tool_resume_event.clear() - if self._tool_confirmation_value: - return self._tool_confirmation_value.model_dump( - mode="python", by_alias=False - ) + """For the case where there's no tool confirmation and the client side tool sends endToolCall back before wait_for_resume is called. + Unlikely in practice, but possible in theory, since executingToolCall is emitted during the streaming. + """ + if value: + return value.model_dump(mode="python", by_alias=False) return {} @property @@ -424,13 +448,13 @@ async def _handle_conversation_event( parsed_event.exchange and parsed_event.exchange.message and (tool_call := parsed_event.exchange.message.tool_call) - and (confirm := tool_call.confirm) ): - logger.info( - f"Received confirmToolCall for tool_call_id: {tool_call.tool_call_id}, approved: {confirm.approved}" - ) - self._tool_confirmation_value = confirm - self._tool_confirmation_event.set() + if confirm := tool_call.confirm: + self._tool_resume_value = confirm + self._tool_resume_event.set() + elif end := tool_call.end: + self._tool_resume_value = end + self._tool_resume_event.set() except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 6fda35789..7123a43e0 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -117,6 +117,7 @@ class AgentToolType(str, CaseInsensitiveEnum): INTEGRATION = "Integration" INTERNAL = "Internal" IXP = "Ixp" + CLIENT_SIDE = "ClientSide" UNKNOWN = "Unknown" # fallback branch discriminator @@ -945,6 +946,15 @@ class AgentInternalToolResourceConfig(BaseAgentToolResourceConfig): ) +class AgentClientSideToolResourceConfig(BaseAgentToolResourceConfig): + """Resource config for client-side tools executed by the client SDK.""" + + type: Literal[AgentToolType.CLIENT_SIDE] = AgentToolType.CLIENT_SIDE + properties: BaseResourceProperties = Field(default_factory=BaseResourceProperties) + output_schema: Optional[Dict[str, Any]] = Field(None, alias="outputSchema") + arguments: Optional[Dict[str, Any]] = Field(default_factory=dict) + + class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): """Fallback for unknown tool types (parent normalizer sets type='Unknown').""" @@ -958,6 +968,7 @@ class AgentUnknownToolResourceConfig(BaseAgentToolResourceConfig): AgentIntegrationToolResourceConfig, AgentInternalToolResourceConfig, AgentIxpExtractionResourceConfig, + AgentClientSideToolResourceConfig, AgentUnknownToolResourceConfig, # when parent sets type="Unknown" ], Field(discriminator="type"), @@ -1342,6 +1353,7 @@ def _normalize_resources(v: Dict[str, Any]) -> None: "integration": "Integration", "internal": "Internal", "ixp": "Ixp", + "clientside": "ClientSide", "unknown": "Unknown", } CONTEXT_MODE_MAP = { diff --git a/packages/uipath/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py index 6b2825833..fcfefa946 100644 --- a/packages/uipath/tests/agent/models/test_agent.py +++ b/packages/uipath/tests/agent/models/test_agent.py @@ -8,6 +8,7 @@ AgentBooleanOperator, AgentBooleanRule, AgentBuiltInValidatorGuardrail, + AgentClientSideToolResourceConfig, AgentContextResourceConfig, AgentContextRetrievalMode, AgentContextType, @@ -4088,3 +4089,208 @@ def test_argument_group_name_recipient_missing_argument_name_raises(self): payload = {"type": 8} with pytest.raises(ValidationError): TypeAdapter(AgentEscalationRecipient).validate_python(payload) + + def test_agent_with_client_side_tool(self): + """Test agent with ClientSide tool resource.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000010", + "name": "Agent with ClientSide Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0001-0000-0000-000000000001", + "name": "browser_navigate", + "description": "Navigate to a URL in the browser", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to", + } + }, + "required": ["url"], + }, + "outputSchema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "content": {"type": "string"}, + }, + }, + "arguments": {"timeout": 30}, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + assert config.name == "Agent with ClientSide Tool" + assert len(config.resources) == 1 + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.resource_type == AgentResourceType.TOOL + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "browser_navigate" + assert tool.description == "Navigate to a URL in the browser" + + # Validate input schema + assert tool.input_schema["type"] == "object" + assert "url" in tool.input_schema["properties"] + assert tool.input_schema["required"] == ["url"] + + # Validate outputSchema alias deserializes to output_schema + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "title" in tool.output_schema["properties"] + assert "content" in tool.output_schema["properties"] + + # Validate arguments + assert tool.arguments == {"timeout": 30} + + def test_agent_with_client_side_tool_lowercase_type(self): + """Test that _normalize_resources handles lowercase 'clientside' type.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000011", + "name": "Agent with clientside Tool", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0002-0000-0000-000000000001", + "name": "clipboard_copy", + "description": "Copy text to clipboard", + "location": "external", + "type": "clientside", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + assert tool.type == AgentToolType.CLIENT_SIDE + assert tool.name == "clipboard_copy" + + # output_schema and arguments should default + assert tool.output_schema is None + assert tool.arguments == {} + + def test_agent_with_client_side_tool_output_schema_alias(self): + """Test that the outputSchema alias correctly maps to output_schema.""" + + json_data = { + "version": "1.0.0", + "id": "aaaaaaaa-0000-0000-0000-000000000012", + "name": "Agent with ClientSide outputSchema alias", + "metadata": {"isConversational": False, "storageVersion": "26.0.0"}, + "messages": [ + {"role": "System", "content": "You are an agentic assistant."}, + ], + "inputSchema": {"type": "object", "properties": {}}, + "outputSchema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + }, + "settings": { + "model": "gpt-4o-2024-11-20", + "maxTokens": 16384, + "temperature": 0, + "engine": "basic-v2", + }, + "resources": [ + { + "$resourceType": "tool", + "id": "cst-0003-0000-0000-000000000001", + "name": "screen_capture", + "description": "Capture a screenshot", + "location": "external", + "type": "ClientSide", + "inputSchema": { + "type": "object", + "properties": { + "region": {"type": "string"}, + }, + }, + "outputSchema": { + "type": "object", + "properties": { + "imageBase64": { + "type": "string", + "description": "Base64-encoded image", + } + }, + "required": ["imageBase64"], + }, + "properties": {}, + "isEnabled": True, + } + ], + "features": [], + } + + config: AgentDefinition = TypeAdapter(AgentDefinition).validate_python( + json_data + ) + + tool = config.resources[0] + assert isinstance(tool, AgentClientSideToolResourceConfig) + + # Access via Python attribute name (snake_case) + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "imageBase64" in tool.output_schema["properties"] + assert tool.output_schema["required"] == ["imageBase64"] diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2da4f31ad..bbd385def 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -1,5 +1,6 @@ """Tests for SocketIOChatBridge and get_chat_bridge.""" +import asyncio import logging from datetime import datetime from typing import Any, cast @@ -9,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger class MockRuntimeContext: @@ -351,3 +353,254 @@ async def test_send_with_datetime_does_not_raise(self) -> None: assert parsed_data["message"] == "test message" assert isinstance(parsed_data["timestamp"], str) assert isinstance(parsed_data["nested"]["created_at"], str) + + +class TestEmitInterruptEvent: + """Tests for emit_interrupt_event (now a no-op for executingToolCall).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emit_interrupt_event_is_noop(self) -> None: + """emit_interrupt_event no longer emits executingToolCall.""" + bridge = self._make_bridge() + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger( + request={ + "tool_call_id": "tc-42", + "tool_name": "my_tool", + "input": {"key": "value"}, + } + ) + ) + + await bridge.emit_interrupt_event(trigger) + + assert len(emitted_events) == 0 + + +class TestEmitExecutingToolCall: + """Tests for emit_executing_tool_call_event (post-confirmation executingToolCall emission).""" + + def _make_bridge(self) -> SocketIOChatBridge: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._current_message_id = "msg-100" + return bridge + + @pytest.mark.anyio + async def test_emits_executing_tool_call_event( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit executingToolCall with tool_call_id and input.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event( + tool_call_id="tc-42", + tool_input={"key": "value"}, + ) + + assert len(emitted_events) == 1 + event = emitted_events[0] + assert event.message_id == "msg-100" + assert event.tool_call is not None + assert event.tool_call.tool_call_id == "tc-42" + assert event.tool_call.executing is not None + assert event.tool_call.executing.input == {"key": "value"} + + @pytest.mark.anyio + async def test_no_message_id_does_not_emit(self) -> None: + """Should not emit if no current message ID is set.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + # _current_message_id is not set + + emitted_events: list[Any] = [] + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 0 + + @pytest.mark.anyio + async def test_none_input_emits_with_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Should emit with None input when no input provided.""" + monkeypatch.setenv("CAS_WEBSOCKET_DISABLED", "true") + bridge = self._make_bridge() + await bridge.connect() + + emitted_events: list[Any] = [] + original_emit = bridge.emit_message_event + + async def capture_emit(event: Any) -> None: + emitted_events.append(event) + await original_emit(event) + + bridge.emit_message_event = capture_emit # type: ignore[assignment] + + await bridge.emit_executing_tool_call_event(tool_call_id="tc-42") + + assert len(emitted_events) == 1 + assert emitted_events[0].tool_call.executing.input is None + + +class TestWaitForResumeEndToolCall: + """Tests for wait_for_resume unblocking on endToolCall events.""" + + @pytest.mark.anyio + async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving an endToolCall event unblocks wait_for_resume and returns parsed payload.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "endToolCall": { + "output": {"result": "ok"}, + "isError": False, + }, + }, + }, + }, + } + + async def simulate_end_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(end_event, "sid-1") + + task = asyncio.create_task(simulate_end_event()) + result = await bridge.wait_for_resume() + await task + + assert result["output"] == {"result": "ok"} + assert result["is_error"] is False + + @pytest.mark.anyio + async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: + """Receiving a confirmToolCall event also unblocks wait_for_resume.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + confirm_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-200", + "toolCall": { + "toolCallId": "tc-99", + "confirmToolCall": { + "approved": True, + "input": {"edited": "data"}, + }, + }, + }, + }, + } + + async def simulate_confirm_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event(confirm_event, "sid-1") + + task = asyncio.create_task(simulate_confirm_event()) + result = await bridge.wait_for_resume() + await task + + assert result["approved"] is True + assert result["input"] == {"edited": "data"} + + @pytest.mark.anyio + async def test_early_end_tool_call_is_not_lost(self) -> None: + """An endToolCall that arrives before wait_for_resume is called must not be lost.""" + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + + end_event = { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "msg-300", + "toolCall": { + "toolCallId": "tc-100", + "endToolCall": { + "output": {"early": True}, + "isError": False, + }, + }, + }, + }, + } + + # Simulate the event arriving BEFORE wait_for_resume is called + await bridge._handle_conversation_event(end_event, "sid-1") + + result = await bridge.wait_for_resume() + + assert result["output"] == {"early": True} + assert result["is_error"] is False diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index b319fa1ba..3cf75bd40 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.72" +version = "2.10.73" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.16" +version = "0.5.17" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" },