diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..48a175c5a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.13" 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 442322e36..c99007864 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -4,6 +4,7 @@ import json import logging import os +from collections import deque from typing import Any from urllib.parse import urlparse @@ -25,6 +26,14 @@ logger = logging.getLogger(__name__) +# Type for tool call resume values (confirmToolCall or endToolCall payloads) +ToolResumeValue = ( + UiPathConversationToolCallConfirmationEvent | UiPathConversationToolCallEndEvent +) + +# Wrapper that pairs a resume value with its tool_call_id for keyed matching +ToolResumeItem = dict[str, Any] # {"tool_call_id": str, "value": ToolResumeValue} + class CASErrorId: """Error IDs for the Conversational Agent Service (CAS), matching the Temporal backend.""" @@ -129,12 +138,29 @@ def __init__( self._client: Any | None = None self._connected_event = asyncio.Event() - self._tool_resume_event = asyncio.Event() - self._tool_resume_value: ( - UiPathConversationToolCallConfirmationEvent - | UiPathConversationToolCallEndEvent - | None - ) = None + # --- Tool call resume state --- + # When the LLM invokes multiple tools in one turn, the client can send + # back confirmToolCall / endToolCall responses concurrently and in any + # order. Three data structures coordinate matching each response to the + # correct wait_for_resume() call: + # + # 1. _expected_tool_call_ids (deque): + # Ordered queue of tool_call_ids populated by emit_interrupt_event() + # (called by the runtime BEFORE each wait_for_resume()). Tells + # wait_for_resume() WHICH tool_call_id it should consume next. + # + # 2. _tool_resume_results (dict): + # Responses that arrived BEFORE wait_for_resume() was called for that + # tool_call_id. When wait_for_resume() runs, it checks here first + # and returns immediately if a match exists — no blocking needed. + # + # 3. _tool_resume_pending (dict of Futures): + # Created by wait_for_resume() when the response hasn't arrived yet. + # When the response later arrives in _handle_conversation_event, the + # Future is resolved and wait_for_resume() unblocks. + self._tool_resume_results: dict[str, ToolResumeItem] = {} + self._tool_resume_pending: dict[str, asyncio.Future[ToolResumeItem]] = {} + self._expected_tool_call_ids: deque[str] = deque() self._current_message_id: str | None = None # Set CAS_WEBSOCKET_DISABLED when using the debugger to prevent websocket errors from @@ -376,14 +402,19 @@ async def emit_exchange_error_event(self, error: Exception) -> None: raise RuntimeError(f"Failed to send exchange error event: {e}") from e async def emit_interrupt_event(self, resume_trigger: UiPathResumeTrigger): - """No-op. + """Register the trigger's tool_call_id for the upcoming wait_for_resume(). - 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). + Does not emit any websocket event — tool confirmation and execution + events are handled elsewhere. The runtime calls this immediately + before wait_for_resume() for each trigger, so we record the + tool_call_id here so wait_for_resume() knows which response to match. """ - return None + if resume_trigger.api_resume and isinstance( + resume_trigger.api_resume.request, dict + ): + tool_call_id = resume_trigger.api_resume.request.get("tool_call_id") + if isinstance(tool_call_id, str) and tool_call_id: + self._expected_tool_call_ids.append(tool_call_id) async def emit_executing_tool_call_event( self, @@ -410,21 +441,77 @@ async def emit_executing_tool_call_event( await self.emit_message_event(executing_event) async def wait_for_resume(self) -> dict[str, Any]: - """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() + """Wait for a tool resume event (confirmToolCall or endToolCall). + + Pops the next expected tool_call_id (registered by emit_interrupt_event) + and returns the matching response. Two cases: + + 1. Response already arrived (stored in _tool_resume_results) — return + immediately without blocking. + 2. Response hasn't arrived yet — create a Future in _tool_resume_pending, + block until _handle_conversation_event resolves it. + + Returns: + The resume data dict, including ``tool_call_id``. + """ + if not self._expected_tool_call_ids: + raise RuntimeError( + "wait_for_resume() called but no tool_call_id was registered " + "by emit_interrupt_event(). This indicates a caller/protocol mismatch." + ) + + expected_id = self._expected_tool_call_ids.popleft() - value = self._tool_resume_value - self._tool_resume_value = None - self._tool_resume_event.clear() + if expected_id in self._tool_resume_results: + # Response arrived before we got here — return it immediately + item = self._tool_resume_results.pop(expected_id) + else: + # Response hasn't arrived yet — wait for it + future: asyncio.Future[ToolResumeItem] = ( + asyncio.get_running_loop().create_future() + ) + self._tool_resume_pending[expected_id] = future + item = await future + + value = item["value"] + result = value.model_dump(mode="python", by_alias=False) + result["tool_call_id"] = item["tool_call_id"] + return result + + def _resolve_or_store_resume( + self, tool_call_id: str, value: ToolResumeValue + ) -> None: + """Route an incoming confirmToolCall/endToolCall to the correct consumer. - """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. + Called from _handle_conversation_event when a tool resume response + arrives from the client. Two cases: + + 1. wait_for_resume() is already waiting (Future in _tool_resume_pending) + — resolve the Future so it unblocks immediately. + 2. wait_for_resume() hasn't been called yet for this tool_call_id + — store in _tool_resume_results so it's found instantly when + wait_for_resume() runs later. """ - if value: - return value.model_dump(mode="python", by_alias=False) - return {} + item: ToolResumeItem = {"tool_call_id": tool_call_id, "value": value} + if tool_call_id in self._tool_resume_pending: + future = self._tool_resume_pending.pop(tool_call_id) + if not future.done(): + future.set_result(item) + else: + # Future was cancelled or already resolved — store the payload + # so a subsequent wait_for_resume() can still find it. + logger.warning( + f"Resume for tool_call_id={tool_call_id} — " + "future already done, storing as fallback." + ) + self._tool_resume_results[tool_call_id] = item + else: + if tool_call_id in self._tool_resume_results: + logger.warning( + f"Duplicate resume for tool_call_id={tool_call_id} — " + "overwriting previously stored result." + ) + self._tool_resume_results[tool_call_id] = item @property def is_connected(self) -> bool: @@ -461,11 +548,9 @@ async def _handle_conversation_event( and (tool_call := parsed_event.exchange.message.tool_call) ): if confirm := tool_call.confirm: - self._tool_resume_value = confirm - self._tool_resume_event.set() + self._resolve_or_store_resume(tool_call.tool_call_id, confirm) elif end := tool_call.end: - self._tool_resume_value = end - self._tool_resume_event.set() + self._resolve_or_store_resume(tool_call.tool_call_id, end) except Exception as e: logger.warning(f"Error parsing conversation event: {e}") diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 2c18640f3..398adad2d 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -488,7 +488,7 @@ async def test_send_with_datetime_does_not_raise(self) -> None: class TestEmitInterruptEvent: - """Tests for emit_interrupt_event (now a no-op for executingToolCall).""" + """Tests for emit_interrupt_event — registers expected tool_call_ids.""" def _make_bridge(self) -> SocketIOChatBridge: bridge = SocketIOChatBridge( @@ -502,8 +502,8 @@ def _make_bridge(self) -> SocketIOChatBridge: return bridge @pytest.mark.anyio - async def test_emit_interrupt_event_is_noop(self) -> None: - """emit_interrupt_event no longer emits executingToolCall.""" + async def test_emit_interrupt_event_does_not_emit_websocket_event(self) -> None: + """emit_interrupt_event does not emit any websocket event.""" bridge = self._make_bridge() emitted_events: list[Any] = [] @@ -527,6 +527,47 @@ async def capture_emit(event: Any) -> None: assert len(emitted_events) == 0 + @pytest.mark.anyio + async def test_emit_interrupt_event_registers_tool_call_id(self) -> None: + """emit_interrupt_event adds the tool_call_id to the expected queue.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger( + request={"tool_call_id": "tc-42", "tool_name": "my_tool"} + ) + ) + + await bridge.emit_interrupt_event(trigger) + + assert list(bridge._expected_tool_call_ids) == ["tc-42"] + + @pytest.mark.anyio + async def test_emit_interrupt_event_skips_without_tool_call_id(self) -> None: + """emit_interrupt_event does not register if tool_call_id is missing.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_name": "my_tool"}) + ) + + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_registers_multiple_in_order(self) -> None: + """Multiple emit_interrupt_event calls register IDs in FIFO order.""" + bridge = self._make_bridge() + + for tc_id in ["tc-1", "tc-2", "tc-3"]: + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tc_id}) + ) + await bridge.emit_interrupt_event(trigger) + + assert list(bridge._expected_tool_call_ids) == ["tc-1", "tc-2", "tc-3"] + class TestEmitExecutingToolCall: """Tests for emit_executing_tool_call_event (post-confirmation executingToolCall emission).""" @@ -623,10 +664,8 @@ async def capture_emit(event: Any) -> 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( + def _make_bridge(self) -> SocketIOChatBridge: + return SocketIOChatBridge( websocket_url="wss://test.example.com", websocket_path="/socket.io", conversation_id="conv-123", @@ -634,16 +673,19 @@ async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: headers={}, ) - end_event = { + def _make_end_event(self, tool_call_id: str, output: Any = None) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-99", + "toolCallId": tool_call_id, "endToolCall": { - "output": {"result": "ok"}, + "output": output + if output is not None + else {"result": "ok"}, "isError": False, }, }, @@ -651,36 +693,15 @@ async def test_end_tool_call_unblocks_wait_for_resume(self) -> None: }, } - 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 = { + def _make_confirm_event(self, tool_call_id: str) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-99", + "toolCallId": tool_call_id, "confirmToolCall": { "approved": True, "input": {"edited": "data"}, @@ -690,9 +711,44 @@ async def test_confirm_tool_call_unblocks_wait_for_resume(self) -> None: }, } + async def _register(self, bridge: SocketIOChatBridge, tool_call_id: str) -> None: + """Register an expected tool_call_id via emit_interrupt_event.""" + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tool_call_id}) + ) + await bridge.emit_interrupt_event(trigger) + + @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 = self._make_bridge() + await self._register(bridge, "tc-99") + + async def simulate_end_event() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-99"), "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 + assert result["tool_call_id"] == "tc-99" + + @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 = self._make_bridge() + await self._register(bridge, "tc-99") + async def simulate_confirm_event() -> None: await asyncio.sleep(0.05) - await bridge._handle_conversation_event(confirm_event, "sid-1") + await bridge._handle_conversation_event( + self._make_confirm_event("tc-99"), "sid-1" + ) task = asyncio.create_task(simulate_confirm_event()) result = await bridge.wait_for_resume() @@ -700,11 +756,150 @@ async def simulate_confirm_event() -> None: assert result["approved"] is True assert result["input"] == {"edited": "data"} + assert result["tool_call_id"] == "tc-99" @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( + bridge = self._make_bridge() + await self._register(bridge, "tc-100") + + # Response arrives BEFORE wait_for_resume is called + await bridge._handle_conversation_event( + self._make_end_event("tc-100", output={"early": True}), "sid-1" + ) + + result = await bridge.wait_for_resume() + + assert result["output"] == {"early": True} + assert result["is_error"] is False + assert result["tool_call_id"] == "tc-100" + + @pytest.mark.anyio + async def test_concurrent_tool_calls_all_early(self) -> None: + """Multiple endToolCall responses arriving before any wait_for_resume are all preserved.""" + bridge = self._make_bridge() + + # Runtime registers 3 expected tool calls + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + + # All 3 responses arrive before any wait_for_resume call + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await bridge._handle_conversation_event( + self._make_end_event(tc_id, output={"id": tc_id}), "sid-1" + ) + + # Each wait_for_resume returns the correct result matched by tool_call_id + for tc_id in ["tc-1", "tc-2", "tc-3"]: + result = await bridge.wait_for_resume() + assert result["tool_call_id"] == tc_id + assert result["output"] == {"id": tc_id} + + # All storage is empty after consumption + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + + @pytest.mark.anyio + async def test_concurrent_tool_calls_out_of_order(self) -> None: + """Responses arriving in reverse order are matched to the correct wait_for_resume call.""" + bridge = self._make_bridge() + + # Runtime registers in order: tc-A, tc-B, tc-C + for tc_id in ["tc-A", "tc-B", "tc-C"]: + await self._register(bridge, tc_id) + + # Responses arrive in reverse order: tc-C, tc-B, tc-A + for tc_id in ["tc-C", "tc-B", "tc-A"]: + await bridge._handle_conversation_event( + self._make_end_event(tc_id, output={"id": tc_id}), "sid-1" + ) + + # wait_for_resume consumes in registration order, each gets correct result + result_a = await bridge.wait_for_resume() + assert result_a["tool_call_id"] == "tc-A" + + result_b = await bridge.wait_for_resume() + assert result_b["tool_call_id"] == "tc-B" + + result_c = await bridge.wait_for_resume() + assert result_c["tool_call_id"] == "tc-C" + + @pytest.mark.anyio + async def test_concurrent_mixed_early_and_late(self) -> None: + """Mix of early arrivals and late arrivals are all matched correctly.""" + bridge = self._make_bridge() + + # Register 3 expected tool calls + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + + # tc-1 arrives early (before any wait_for_resume) + await bridge._handle_conversation_event( + self._make_end_event("tc-1", output={"id": "tc-1"}), "sid-1" + ) + + # First wait_for_resume finds tc-1 already in results + result_1 = await bridge.wait_for_resume() + assert result_1["tool_call_id"] == "tc-1" + + # Second wait_for_resume blocks — tc-2 arrives while waiting + async def send_tc2() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-2", output={"id": "tc-2"}), "sid-1" + ) + + task = asyncio.create_task(send_tc2()) + result_2 = await bridge.wait_for_resume() + await task + assert result_2["tool_call_id"] == "tc-2" + + # tc-3 arrives early before third wait_for_resume + await bridge._handle_conversation_event( + self._make_end_event("tc-3", output={"id": "tc-3"}), "sid-1" + ) + result_3 = await bridge.wait_for_resume() + assert result_3["tool_call_id"] == "tc-3" + + @pytest.mark.anyio + async def test_confirm_then_end_same_tool_call(self) -> None: + """Tool with requireConversationalConfirmation: confirm and end are handled sequentially.""" + bridge = self._make_bridge() + + # Runtime registers the same tool_call_id twice (once for confirm, once for end) + await self._register(bridge, "tc-42") + await self._register(bridge, "tc-42") + + # Confirm arrives + await bridge._handle_conversation_event( + self._make_confirm_event("tc-42"), "sid-1" + ) + + # First wait_for_resume gets the confirm + result_confirm = await bridge.wait_for_resume() + assert result_confirm["approved"] is True + assert result_confirm["tool_call_id"] == "tc-42" + + # End arrives after confirm was consumed + async def send_end() -> None: + await asyncio.sleep(0.05) + await bridge._handle_conversation_event( + self._make_end_event("tc-42", output={"done": True}), "sid-1" + ) + + task = asyncio.create_task(send_end()) + result_end = await bridge.wait_for_resume() + await task + assert result_end["output"] == {"done": True} + assert result_end["tool_call_id"] == "tc-42" + + +class TestWaitForResumeEdgeCases: + """Edge case tests to ensure the resume mechanism doesn't crash.""" + + def _make_bridge(self) -> SocketIOChatBridge: + return SocketIOChatBridge( websocket_url="wss://test.example.com", websocket_path="/socket.io", conversation_id="conv-123", @@ -712,16 +907,19 @@ async def test_early_end_tool_call_is_not_lost(self) -> None: headers={}, ) - end_event = { + def _make_end_event(self, tool_call_id: str, output: Any = None) -> dict[str, Any]: + return { "conversationId": "conv-123", "exchange": { "exchangeId": "exch-456", "message": { - "messageId": "msg-300", + "messageId": "msg-200", "toolCall": { - "toolCallId": "tc-100", + "toolCallId": tool_call_id, "endToolCall": { - "output": {"early": True}, + "output": output + if output is not None + else {"result": "ok"}, "isError": False, }, }, @@ -729,10 +927,147 @@ async def test_early_end_tool_call_is_not_lost(self) -> None: }, } - # Simulate the event arriving BEFORE wait_for_resume is called - await bridge._handle_conversation_event(end_event, "sid-1") + async def _register(self, bridge: SocketIOChatBridge, tool_call_id: str) -> None: + trigger = UiPathResumeTrigger( + api_resume=UiPathApiTrigger(request={"tool_call_id": tool_call_id}) + ) + await bridge.emit_interrupt_event(trigger) + + @pytest.mark.anyio + async def test_wait_for_resume_without_registration_raises(self) -> None: + """wait_for_resume with empty deque raises RuntimeError, not IndexError.""" + bridge = self._make_bridge() + + with pytest.raises(RuntimeError, match="no tool_call_id was registered"): + await bridge.wait_for_resume() + + @pytest.mark.anyio + async def test_duplicate_response_stored_does_not_crash(self) -> None: + """Two responses for the same tool_call_id before consumption logs warning, doesn't crash.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-dup") + + # First response stored + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"first": True}), "sid-1" + ) + # Second response overwrites (with warning), but no crash + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"second": True}), "sid-1" + ) result = await bridge.wait_for_resume() + # Second overwrote first + assert result["output"] == {"second": True} - assert result["output"] == {"early": True} - assert result["is_error"] is False + @pytest.mark.anyio + async def test_duplicate_response_pending_does_not_crash(self) -> None: + """Two responses while a Future is pending — first resolves it, second is stored as fallback.""" + bridge = self._make_bridge() + await self._register(bridge, "tc-dup") + + # Start waiting (creates a pending Future) + async def wait() -> dict[str, Any]: + return await bridge.wait_for_resume() + + wait_task = asyncio.create_task(wait()) + await asyncio.sleep(0.02) + + # First response resolves the Future + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"first": True}), "sid-1" + ) + # Second response — Future already resolved, should not crash + await bridge._handle_conversation_event( + self._make_end_event("tc-dup", output={"second": True}), "sid-1" + ) + + result = await wait_task + assert result["output"] == {"first": True} + + @pytest.mark.anyio + async def test_malformed_event_does_not_crash(self) -> None: + """Malformed conversation events are caught and don't crash the bridge.""" + bridge = self._make_bridge() + + # Completely wrong structure + await bridge._handle_conversation_event({"garbage": True}, "sid-1") + + # Missing toolCall + await bridge._handle_conversation_event( + { + "conversationId": "conv-123", + "exchange": {"exchangeId": "exch-456", "message": {"messageId": "m-1"}}, + }, + "sid-1", + ) + + # Missing endToolCall and confirmToolCall + await bridge._handle_conversation_event( + { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "message": { + "messageId": "m-1", + "toolCall": {"toolCallId": "tc-1"}, + }, + }, + }, + "sid-1", + ) + + # No crash — bridge is still functional + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_no_api_resume(self) -> None: + """emit_interrupt_event with no api_resume does not crash or register.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger() + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_emit_interrupt_event_non_dict_request(self) -> None: + """emit_interrupt_event with non-dict request does not crash or register.""" + bridge = self._make_bridge() + + trigger = UiPathResumeTrigger(api_resume=UiPathApiTrigger(request="not-a-dict")) + await bridge.emit_interrupt_event(trigger) + + assert len(bridge._expected_tool_call_ids) == 0 + + @pytest.mark.anyio + async def test_unrequested_response_stored_harmlessly(self) -> None: + """A response for an unregistered tool_call_id is stored without crashing.""" + bridge = self._make_bridge() + + # No registration, but a response arrives + await bridge._handle_conversation_event( + self._make_end_event("tc-unknown", output={"surprise": True}), "sid-1" + ) + + # Stored in results — won't be consumed but doesn't crash + assert "tc-unknown" in bridge._tool_resume_results + + @pytest.mark.anyio + async def test_storage_cleaned_up_after_consumption(self) -> None: + """After all wait_for_resume calls complete, no state is left behind.""" + bridge = self._make_bridge() + + for tc_id in ["tc-1", "tc-2", "tc-3"]: + await self._register(bridge, tc_id) + await bridge._handle_conversation_event( + self._make_end_event(tc_id), "sid-1" + ) + + for _ in range(3): + await bridge.wait_for_resume() + + assert len(bridge._tool_resume_results) == 0 + assert len(bridge._tool_resume_pending) == 0 + assert len(bridge._expected_tool_call_ids) == 0 diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..6dcdf79b3 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer = "2026-06-22T17:13:35.871656Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" },