diff --git a/pyproject.toml b/pyproject.toml index ebe7206..a6db49a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.13.5" +version = "0.13.6" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 9eb31a7..8622acb 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -13,6 +13,7 @@ UiPathStreamOptions, ) from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol +from uipath.runtime.debug import UiPathBreakpointResult from uipath.runtime.errors import UiPathBaseRuntimeError from uipath.runtime.errors.contract import UiPathErrorCategory from uipath.runtime.events import ( @@ -98,7 +99,13 @@ async def stream( if isinstance(event, UiPathRuntimeResult): runtime_result = event - if ( + if isinstance(runtime_result, UiPathBreakpointResult): + # A breakpoint only pauses the run. Ending the exchange + # here would close the turn in CAS before the agent has + # finished replying. + yield event + execution_completed = True + elif ( runtime_result.status == UiPathRuntimeStatus.SUSPENDED and runtime_result.triggers ): diff --git a/tests/test_chat.py b/tests/test_chat.py index f17bad5..f453682 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -17,6 +17,7 @@ ) from uipath.runtime import ( + UiPathBreakpointResult, UiPathExecuteOptions, UiPathRuntimeResult, UiPathRuntimeStatus, @@ -28,6 +29,7 @@ UiPathChatRuntime, ) from uipath.runtime.chat.runtime import _parse_confirmation +from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugRuntime from uipath.runtime.events import ( UiPathRuntimeConversationMetaEvent, UiPathRuntimeEvent, @@ -225,6 +227,7 @@ async def test_chat_runtime_streams_and_emits_messages(): # Bridge lifecycle cast(AsyncMock, bridge.connect).assert_awaited_once() cast(AsyncMock, bridge.disconnect).assert_awaited_once() + cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() assert cast(AsyncMock, bridge.emit_message_event).await_count == 3 @@ -437,6 +440,101 @@ async def test_chat_runtime_yields_events_during_suspension_flow(): assert event.status != UiPathRuntimeStatus.SUSPENDED +class BreakpointMockRuntime: + """Mock runtime that pauses at a debug breakpoint before replying.""" + + async def dispose(self) -> None: + pass + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + raise NotImplementedError() + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + if not (options and options.resume): + yield UiPathBreakpointResult( + breakpoint_node="agent", + breakpoint_type="before", + current_state={}, + next_nodes=["agent"], + ) + return + + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id="msg-0", + start=UiPathConversationMessageStartEvent( + role="assistant", + timestamp="2025-01-01T00:00:00.000Z", + ), + ) + ) + yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError() + + +@pytest.mark.asyncio +async def test_chat_runtime_keeps_exchange_open_at_breakpoint(): + """A debug breakpoint pauses the exchange, so it must not end it.""" + bridge = make_chat_bridge_mock() + chat_runtime = UiPathChatRuntime( + delegate=BreakpointMockRuntime(), chat_bridge=bridge + ) + + events = [event async for event in chat_runtime.stream({})] + + assert len(events) == 1 + assert isinstance(events[0], UiPathBreakpointResult) + cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited() + + +@pytest.mark.asyncio +async def test_chat_runtime_under_debugger_ends_exchange_after_reply(): + """Stepping past a breakpoint ends the exchange once, after the reply.""" + chat_bridge = make_chat_bridge_mock() + debug_bridge: Mock = Mock(spec=UiPathDebugProtocol) + debug_bridge.connect = AsyncMock() + debug_bridge.emit_execution_started = AsyncMock() + debug_bridge.emit_execution_completed = AsyncMock() + debug_bridge.emit_breakpoint_hit = AsyncMock() + debug_bridge.wait_for_resume = AsyncMock() + debug_bridge.get_breakpoints = Mock(return_value="*") + + calls = Mock() + calls.attach_mock( + cast(AsyncMock, chat_bridge.emit_message_event), "emit_message_event" + ) + calls.attach_mock( + cast(AsyncMock, chat_bridge.emit_exchange_end_event), "emit_exchange_end_event" + ) + + # Same nesting as `uipath debug` for a conversational job. + debug_runtime = UiPathDebugRuntime( + delegate=UiPathChatRuntime( + delegate=BreakpointMockRuntime(), chat_bridge=chat_bridge + ), + debug_bridge=cast(UiPathDebugProtocol, debug_bridge), + ) + + result = await debug_runtime.execute({}) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + debug_bridge.emit_breakpoint_hit.assert_awaited_once() + assert [name for name, _, _ in calls.mock_calls] == [ + "emit_message_event", + "emit_exchange_end_event", + ] + + class MultiTriggerMockRuntime: """Mock runtime that suspends with multiple API triggers.""" diff --git a/uv.lock b/uv.lock index 7106406..f4a278a 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.5" +version = "0.13.6" source = { editable = "." } dependencies = [ { name = "chardet" },