diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b59c5e95b..f052cb0fd 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.13.21" +version = "2.15.0" 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.5.30, <0.6.0", - "uipath-runtime>=0.12.2, <0.13.0", + "uipath-runtime>=0.13.0, <0.14.0", "uipath-platform>=0.2.14, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py index bdf620caa..397b7454e 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_bridge.py @@ -273,6 +273,14 @@ async def disconnect(self) -> None: finally: await self._cleanup_client() + def _require_client(self) -> Any: + client = self._client + if client is None: + raise RuntimeError("WebSocket client not connected. Call connect() first.") + if not self._connected_event.is_set() and not self._websocket_disabled: + raise RuntimeError("WebSocket client not in connected state") + return client + async def emit_message_event( self, message_event: UiPathConversationMessageEvent ) -> None: @@ -284,11 +292,7 @@ async def emit_message_event( Raises: RuntimeError: If client is not connected """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: # Wrap message event with conversation/exchange IDs @@ -309,7 +313,7 @@ async def emit_message_event( f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) # Store the current message ID, used for emitting interrupt events. self._current_message_id = message_event.message_id @@ -318,6 +322,31 @@ async def emit_message_event( logger.error(f"Error sending conversation event to WebSocket: {e}") raise RuntimeError(f"Failed to send conversation event: {e}") from e + async def emit_meta_event(self, meta_event: dict[str, Any]) -> None: + """Send an exchange-scoped conversation metadata event.""" + client = self._require_client() + + try: + event = UiPathConversationEvent( + conversation_id=self.conversation_id, + exchange=UiPathConversationExchangeEvent( + exchange_id=self.exchange_id, + meta_event=meta_event, + ), + ) + event_data = event.model_dump(mode="json", exclude_none=True, by_alias=True) + + if self._websocket_disabled: + logger.info( + "SocketIOChatBridge is in debug mode. Not sending event: %s", + json.dumps(event_data), + ) + else: + await client.emit("ConversationEvent", event_data) + except Exception as e: + logger.error(f"Error sending conversation event to WebSocket: {e}") + raise RuntimeError(f"Failed to send conversation event: {e}") from e + async def emit_exchange_end_event(self) -> None: """Send an exchange end event. @@ -331,11 +360,7 @@ async def emit_exchange_end_event(self) -> None: logger.info("end_exchange is False; leaving the exchange open.") return - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() try: exchange_end_event = UiPathConversationEvent( @@ -355,7 +380,7 @@ async def emit_exchange_end_event(self) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending conversation event to WebSocket: {e}") @@ -371,11 +396,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: Args: error: The exception that caused the error. """ - if self._client is None: - raise RuntimeError("WebSocket client not connected. Call connect() first.") - - if not self._connected_event.is_set() and not self._websocket_disabled: - raise RuntimeError("WebSocket client not in connected state") + client = self._require_client() # Extract and map error to CAS-specific error ID and message. cas_error_id, cas_message = _resolve_cas_error(error) @@ -403,7 +424,7 @@ async def emit_exchange_error_event(self, error: Exception) -> None: f"SocketIOChatBridge is in debug mode. Not sending event: {json.dumps(event_data)}" ) else: - await self._client.emit("ConversationEvent", event_data) + await client.emit("ConversationEvent", event_data) except Exception as e: logger.error(f"Error sending exchange error event to WebSocket: {e}") diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 7f542e871..ffb109ad7 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,5 +1,6 @@ import asyncio import logging +from contextlib import AsyncExitStack from typing import Any, cast, get_args import click @@ -11,17 +12,23 @@ from uipath._cli._utils._tracing import create_trace_manager from uipath.eval.mocks import UiPathMockRuntime from uipath.eval.mocks._mock_runtime import load_simulation_config +from uipath.platform import UiPath from uipath.platform.common import ( ExecutionSourceContext, ResourceOverwritesContext, UiPathConfig, ) from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, UiPathExecuteOptions, UiPathRuntimeContext, UiPathRuntimeFactoryProtocol, UiPathRuntimeFactoryRegistry, UiPathRuntimeProtocol, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, ) from uipath.runtime.chat import UiPathChatProtocol, UiPathChatRuntime from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugRuntime @@ -180,69 +187,139 @@ async def execute_debug_runtime(): async def execute_debug_runtime(): chat_runtime: UiPathRuntimeProtocol | None = None - debug_bridge: UiPathDebugProtocol = get_debug_bridge( - ctx, attach=attach_mode - ) - new_runtime_kwargs: dict[str, Any] = {} - if governance_bootstrap is not None: - new_runtime_kwargs["evaluator"] = ( - governance_bootstrap.evaluator + workspace: Workspace | None = None + hydration_runtime: HydrationRuntime | None = None + conversational_workspace_runtime: ( + ConversationalWorkspaceRuntime | None + ) = None + debug_runtime: UiPathRuntimeProtocol | None = None + mock_runtime: UiPathRuntimeProtocol | None = None + runtime: UiPathRuntimeProtocol | None = None + try: + debug_bridge: UiPathDebugProtocol = get_debug_bridge( + ctx, attach=attach_mode ) - runtime = await factory.new_runtime( - entrypoint, - governance_runtime_id, - **new_runtime_kwargs, - ) - - if governance_bootstrap is not None: - runtime = governance_bootstrap.wrap_runtime( - runtime, - agent_name=entrypoint, - runtime_id=governance_runtime_id, + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) + runtime = await factory.new_runtime( + entrypoint, + governance_runtime_id, + **new_runtime_kwargs, ) - delegate = runtime - if ctx.conversation_id and ctx.exchange_id: - chat_bridge: UiPathChatProtocol = get_chat_bridge( - context=ctx - ) - chat_runtime = UiPathChatRuntime( - delegate=delegate, chat_bridge=chat_bridge - ) - delegate = chat_runtime + if governance_bootstrap is not None: + runtime = governance_bootstrap.wrap_runtime( + runtime, + agent_name=entrypoint, + runtime_id=governance_runtime_id, + ) - debug_runtime = UiPathDebugRuntime( - delegate=delegate, - debug_bridge=debug_bridge, - trigger_poll_interval=trigger_poll_interval, - ) + delegate = runtime + if ( + ctx.job_id is not None + and factory_settings is not None + and factory_settings.managed_workspace + ): + storage = await factory.get_storage() + if storage is None: + raise RuntimeError( + "Runtime factory advertises managed workspace " + "support but provides no storage" + ) - # Build mocking context with agent model for simulations - schema = await runtime.get_schema() - agent_model = None - if schema.metadata and "settings" in schema.metadata: - agent_model = schema.metadata["settings"].get("model") + client = UiPath() + workspace = Workspace.create() + workspace.path = workspace.path.resolve() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=client.attachments, + jobs=client.jobs, + current_job_key=ctx.job_id, + folder_key=ctx.folder_key, + ) + registry_store = WorkspaceRegistryStore( + storage, ctx.job_id + ) + hydration_runtime = HydrationRuntime( + runtime, + workspace=workspace, + hydrator=hydrator, + registry_store=registry_store, + ) + delegate = hydration_runtime - mocking_context = load_simulation_config( - agent_model=agent_model - ) + if ( + ctx.conversation_id is not None + and ctx.exchange_id is not None + ): + conversational_workspace_runtime = ( + ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + ) + ) + delegate = conversational_workspace_runtime - mock_runtime = UiPathMockRuntime( - delegate=debug_runtime, - mocking_context=mocking_context, - ) + if ctx.conversation_id and ctx.exchange_id: + chat_bridge: UiPathChatProtocol = get_chat_bridge( + context=ctx + ) + chat_runtime = UiPathChatRuntime( + delegate=delegate, chat_bridge=chat_bridge + ) + delegate = chat_runtime + + debug_runtime = UiPathDebugRuntime( + delegate=delegate, + debug_bridge=debug_bridge, + trigger_poll_interval=trigger_poll_interval, + ) + + schema = await runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get( + "model" + ) + + mocking_context = load_simulation_config( + agent_model=agent_model + ) + + mock_runtime = UiPathMockRuntime( + delegate=debug_runtime, + mocking_context=mocking_context, + ) - try: ctx.result = await mock_runtime.execute( ctx.get_input(), options=UiPathExecuteOptions(resume=resume), ) finally: - await mock_runtime.dispose() - await debug_runtime.dispose() + cleanup = AsyncExitStack() + if hydration_runtime is None: + if runtime is not None: + cleanup.push_async_callback(runtime.dispose) + if workspace is not None: + cleanup.push_async_callback(workspace.dispose) + if hydration_runtime is not None: + cleanup.push_async_callback( + hydration_runtime.dispose + ) + if conversational_workspace_runtime is not None: + cleanup.push_async_callback( + conversational_workspace_runtime.dispose + ) if chat_runtime: - await chat_runtime.dispose() - await runtime.dispose() + cleanup.push_async_callback(chat_runtime.dispose) + if debug_runtime is not None: + cleanup.push_async_callback(debug_runtime.dispose) + if mock_runtime is not None: + cleanup.push_async_callback(mock_runtime.dispose) + await cleanup.aclose() if project_id := UiPathConfig.project_id: studio_client = StudioClient(project_id) diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9d12a86c3..9bf9034f5 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import AsyncExitStack from typing import Any import click @@ -10,18 +11,24 @@ from uipath._cli._utils._debug import setup_debugging from uipath._cli._utils._tracing import create_trace_manager from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context +from uipath.platform import UiPath from uipath.platform.common import ( ExecutionSourceContext, ResourceOverwritesContext, UiPathConfig, ) from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, UiPathExecuteOptions, UiPathRuntimeFactoryProtocol, UiPathRuntimeFactoryRegistry, UiPathRuntimeProtocol, UiPathRuntimeResult, UiPathStreamOptions, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, ) from uipath.runtime.chat import UiPathChatProtocol, UiPathChatRuntime from uipath.runtime.context import UiPathRuntimeContext @@ -219,6 +226,11 @@ async def execute() -> None: base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None + workspace: Workspace | None = None + hydration_runtime: HydrationRuntime | None = None + conversational_workspace_runtime: ( + ConversationalWorkspaceRuntime | None + ) = None factory: UiPathRuntimeFactoryProtocol | None = None governance_bootstrap: GovernanceBootstrap | None = None try: @@ -293,6 +305,51 @@ async def execute() -> None: mocking_context=mocking_context, ) + if ( + ctx.job_id is not None + and factory_settings is not None + and factory_settings.managed_workspace + ): + storage = await factory.get_storage() + if storage is None: + raise RuntimeError( + "Runtime factory advertises managed workspace " + "support but provides no storage" + ) + + client = UiPath() + workspace = Workspace.create() + workspace.path = workspace.path.resolve() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=client.attachments, + jobs=client.jobs, + current_job_key=ctx.job_id, + folder_key=ctx.folder_key, + ) + registry_store = WorkspaceRegistryStore( + storage, ctx.job_id + ) + hydration_runtime = HydrationRuntime( + runtime, + workspace=workspace, + hydrator=hydrator, + registry_store=registry_store, + ) + runtime = hydration_runtime + + if ( + ctx.conversation_id is not None + and ctx.exchange_id is not None + ): + conversational_workspace_runtime = ( + ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + ) + ) + runtime = conversational_workspace_runtime + if ctx.job_id: if UiPathConfig.is_tracing_enabled: trace_manager.add_span_processor( @@ -316,19 +373,31 @@ async def execute() -> None: else: ctx.result = await debug_runtime(ctx, runtime) finally: - try: - if chat_runtime: - await chat_runtime.dispose() + cleanup = AsyncExitStack() + cleanup.callback(trace_manager.shutdown) + if factory: + cleanup.push_async_callback(factory.dispose) + if governance_bootstrap is not None: + cleanup.callback(governance_bootstrap.dispose) + if base_runtime is not None and ( + hydration_runtime is None + or hydration_runtime.delegate is not base_runtime + ): + cleanup.push_async_callback(base_runtime.dispose) + if hydration_runtime is None: if runtime is not None and runtime is not base_runtime: - await runtime.dispose() - if base_runtime is not None: - await base_runtime.dispose() - if governance_bootstrap is not None: - governance_bootstrap.dispose() - if factory: - await factory.dispose() - finally: - trace_manager.shutdown() + cleanup.push_async_callback(runtime.dispose) + if workspace is not None: + cleanup.push_async_callback(workspace.dispose) + if hydration_runtime is not None: + cleanup.push_async_callback(hydration_runtime.dispose) + if conversational_workspace_runtime is not None: + cleanup.push_async_callback( + conversational_workspace_runtime.dispose + ) + if chat_runtime: + cleanup.push_async_callback(chat_runtime.dispose) + await cleanup.aclose() asyncio.run(execute()) diff --git a/packages/uipath/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml index 153ea57d8..58c3c38d2 100644 --- a/packages/uipath/testcases/langchain-cross/pyproject.toml +++ b/packages/uipath/testcases/langchain-cross/pyproject.toml @@ -18,4 +18,9 @@ uipath-platform = { path = "../../../uipath-platform", editable = true } # the published uipath-langchain package. Mirrors the uv override used in the # cross-repo test workflows. [tool.uv] -override-dependencies = ["uipath", "uipath-core", "uipath-platform"] +override-dependencies = [ + "uipath", + "uipath-core", + "uipath-platform", + "uipath-runtime>=0.13.0,<0.14.0", +] diff --git a/packages/uipath/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py index 6c67bc6a9..2c4aedbe9 100644 --- a/packages/uipath/tests/cli/chat/test_bridge.py +++ b/packages/uipath/tests/cli/chat/test_bridge.py @@ -10,6 +10,7 @@ from uipath._cli._chat._bridge import SocketIOChatBridge, get_chat_bridge from uipath._cli._debug._bridge import SignalRDebugBridge +from uipath.core.chat import UiPathConversationMessageEvent from uipath.core.triggers import UiPathApiTrigger, UiPathResumeTrigger from uipath.platform.constants import ( HEADER_INTERNAL_ACCOUNT_ID, @@ -362,6 +363,130 @@ async def test_emit_exchange_end_raises_without_client(self) -> None: assert "not connected" in str(exc_info.value).lower() + @pytest.mark.anyio + async def test_emit_message_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_message_event( + UiPathConversationMessageEvent(message_id="msg-123") + ) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_exchange_error_event_sends_when_connected(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_exchange_error_event(ValueError("failed")) + + bridge._client.emit.assert_awaited_once() + + @pytest.mark.anyio + async def test_emit_meta_event_sends_exchange_scoped_event(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._connected_event.set() + + await bridge.emit_meta_event( + {"workspaceFiles": [{"path": "plan.md", "attachmentKey": "key-1"}]} + ) + + bridge._client.emit.assert_awaited_once_with( + "ConversationEvent", + { + "conversationId": "conv-123", + "exchange": { + "exchangeId": "exch-456", + "metaEvent": { + "workspaceFiles": [ + {"path": "plan.md", "attachmentKey": "key-1"} + ] + }, + }, + }, + ) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_client(self) -> None: + 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, match="not connected"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_requires_connected_client(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + + with pytest.raises(RuntimeError, match="not in connected state"): + await bridge.emit_meta_event({}) + + @pytest.mark.anyio + async def test_emit_meta_event_does_not_send_in_debug_mode(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._websocket_disabled = True + + await bridge.emit_meta_event({"workspaceFiles": []}) + + bridge._client.emit.assert_not_awaited() + + @pytest.mark.anyio + async def test_emit_meta_event_wraps_send_error(self) -> None: + bridge = SocketIOChatBridge( + websocket_url="wss://test.example.com", + websocket_path="/socket.io", + conversation_id="conv-123", + exchange_id="exch-456", + headers={}, + ) + bridge._client = AsyncMock() + bridge._client.emit.side_effect = ValueError("socket failed") + bridge._connected_event.set() + + with pytest.raises(RuntimeError, match="Failed to send conversation event"): + await bridge.emit_meta_event({}) + class TestSocketIOChatBridgeEndExchange: """The bridge owns whether to honor the exchange-end event (CAS-specific).""" diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index 9e66a1a24..2e35475a1 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -19,6 +19,11 @@ LLMMockingStrategy, MockingContext, ) +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathRuntimeFactorySettings, +) MOCK_RUNTIME_PATCH_PATH = "uipath.eval.mocks._mock_runtime" @@ -336,6 +341,196 @@ def test_debug_wraps_with_mock_runtime_on_error( # Verify UiPathMockRuntime was still instantiated assert mock_mock_runtime_class.called + def test_installs_workspace_runtimes_in_debug_chain( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock( + get_schema=AsyncMock(return_value=Mock(metadata=None)), + dispose=AsyncMock(), + ) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + client = Mock(attachments=Mock(), jobs=Mock()) + chat_runtime = Mock(dispose=AsyncMock()) + debug_runtime = Mock(dispose=AsyncMock()) + mock_runtime = Mock( + execute=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "00000000-0000-0000-0000-000000000001") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli.cli_debug.UiPath", return_value=client), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch("uipath._cli.cli_debug.get_chat_bridge"), + patch("uipath._cli.cli_debug.UiPathChatRuntime") as chat_runtime_type, + patch("uipath._cli.cli_debug.UiPathDebugRuntime") as debug_runtime_type, + patch("uipath._cli.cli_debug.UiPathMockRuntime") as mock_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + debug_runtime_type.return_value = debug_runtime + mock_runtime_type.return_value = mock_runtime + result = runner.invoke(cli, ["debug", "main", "{}"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + workspace_runtime = chat_runtime_type.call_args.kwargs["delegate"] + assert isinstance(workspace_runtime, ConversationalWorkspaceRuntime) + assert isinstance(workspace_runtime.delegate, HydrationRuntime) + assert workspace_runtime.delegate.delegate is base_runtime + assert workspace_runtime.registry_store is None + assert ( + workspace_runtime.delegate.registry_store.runtime_id + == "00000000-0000-0000-0000-000000000001" + ) + assert debug_runtime_type.call_args.kwargs["delegate"] is chat_runtime + assert mock_runtime_type.call_args.kwargs["delegate"] is debug_runtime + assert not workspace_runtime.delegate.workspace.path.exists() + factory.get_storage.assert_awaited_once() + mock_runtime.dispose.assert_awaited_once() + debug_runtime.dispose.assert_awaited_once() + chat_runtime.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + + def test_disposes_runtime_when_managed_workspace_has_no_storage( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock(dispose=AsyncMock()) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=None), + dispose=AsyncMock(), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + ): + runner.invoke(cli, ["debug", "main", "{}"]) + + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + + def test_cleanup_continues_after_workspace_construction_failure( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock(dispose=AsyncMock()) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + workspace = Mock( + path=Path(temp_dir), + dispose=AsyncMock(side_effect=RuntimeError("cleanup failed")), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch("uipath._cli.cli_debug.UiPath"), + patch( + "uipath._cli.cli_debug.Workspace.create", + return_value=workspace, + ), + patch( + "uipath._cli.cli_debug.WorkspaceHydrator", + side_effect=RuntimeError("construction failed"), + ), + ): + runner.invoke(cli, ["debug", "main", "{}"]) + + workspace.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + def test_simulation_config_enables_tool_mocking( self, temp_dir: str, valid_simulation_config: dict[str, Any] ): diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index aa182c7c5..f713df213 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,14 +1,25 @@ # type: ignore +import hashlib import json import os from contextlib import asynccontextmanager +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch +from uuid import UUID import pytest from click.testing import CliRunner from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathRuntimeFactorySettings, + UiPathRuntimeResult, + UiPathRuntimeStatus, + get_workspace_path, +) def _middleware_continue(): @@ -322,6 +333,311 @@ def test_successful_execution( output = f.read() assert output.count("Hello world") >= 2 + def test_installs_workspace_runtimes_before_chat_runtime( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + storage = Mock() + factory.get_storage = AsyncMock(return_value=storage) + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + chat_bridge = Mock() + chat_runtime = Mock( + execute=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + client = Mock(attachments=Mock(), jobs=Mock()) + + monkeypatch.setenv("UIPATH_JOB_KEY", "00000000-0000-0000-0000-000000000001") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch( + "uipath._cli.cli_run.UiPath", + return_value=client, + ), + patch( + "uipath._cli.cli_run.get_chat_bridge", + return_value=chat_bridge, + ), + patch("uipath._cli.cli_run.UiPathChatRuntime") as chat_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + result = runner.invoke(cli, ["run", "main"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + wrapped_runtime = chat_runtime_type.call_args.kwargs["delegate"] + assert isinstance(wrapped_runtime, ConversationalWorkspaceRuntime) + assert isinstance(wrapped_runtime.delegate, HydrationRuntime) + assert wrapped_runtime.delegate.delegate is base_runtime + assert wrapped_runtime.registry_store is None + assert ( + wrapped_runtime.delegate.registry_store.runtime_id + == "00000000-0000-0000-0000-000000000001" + ) + assert not wrapped_runtime.delegate.workspace.path.exists() + factory.get_storage.assert_awaited_once() + chat_runtime.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + + def test_suspended_workspace_takes_precedence_over_conversation_snapshot( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + conversation_attachment_key = UUID(int=1) + suspended_attachment_key = UUID(int=2) + job_key = UUID(int=3) + attachment_contents = { + conversation_attachment_key: b"conversation", + suspended_attachment_key: b"suspended", + } + + async def download_attachment(key, destination_path, **_): + Path(destination_path).write_bytes(attachment_contents[key]) + + attachments = Mock( + download_async=AsyncMock(side_effect=download_attachment), + upload_async=AsyncMock(return_value=UUID(int=4)), + ) + client = Mock( + attachments=attachments, + jobs=Mock(link_attachment_async=AsyncMock()), + ) + storage = Mock( + get_value=AsyncMock( + return_value={ + "notes.txt": { + "attachment_key": str(suspended_attachment_key), + "sha256": hashlib.sha256(b"suspended").hexdigest(), + "size": len(b"suspended"), + "uploaded_at": "2026-01-01T00:00:00+00:00", + "attachment_name": ".uipath-workspace~1notes.txt", + } + } + ), + set_value=AsyncMock(), + ) + observed_contents = [] + + async def stream_runtime(*_, **__): + observed_contents.append( + (get_workspace_path() / "notes.txt").read_text(encoding="utf-8") + ) + yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) + + base_runtime = Mock( + stream=Mock(side_effect=stream_runtime), + dispose=AsyncMock(), + ) + factory = Mock( + discover_entrypoints=Mock(return_value=["main"]), + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=storage), + dispose=AsyncMock(), + ) + chat_runtime = Mock(dispose=AsyncMock()) + + async def execute_chat(input, options): + workspace_runtime = chat_runtime_type.call_args.kwargs["delegate"] + return await workspace_runtime.execute(input, options=options) + + chat_runtime.execute = AsyncMock(side_effect=execute_chat) + + monkeypatch.setenv("UIPATH_JOB_KEY", str(job_key)) + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + input = { + "uipath__conversation_meta_events": [ + { + "metaEvent": { + "workspaceFiles": [ + { + "path": "notes.txt", + "attachmentKey": str(conversation_attachment_key), + } + ] + } + } + ] + } + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPath", return_value=client), + patch("uipath._cli.cli_run.get_chat_bridge"), + patch("uipath._cli.cli_run.UiPathChatRuntime") as chat_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + result = runner.invoke( + cli, + ["run", "main", json.dumps(input)], + ) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + assert observed_contents == ["suspended"] + downloaded_keys = [ + call.kwargs["key"] + for call in attachments.download_async.await_args_list + ] + assert downloaded_keys == [ + conversation_attachment_key, + suspended_attachment_key, + ] + base_runtime.dispose.assert_awaited_once() + + def test_disposes_runtime_when_managed_workspace_has_no_storage( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + factory.get_storage = AsyncMock(return_value=None) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + ): + runner.invoke(cli, ["run", "main"]) + + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + + def test_cleanup_continues_after_workspace_construction_failure( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + factory.get_storage = AsyncMock(return_value=Mock()) + workspace = Mock( + path=Path(temp_dir), + dispose=AsyncMock(side_effect=RuntimeError("cleanup failed")), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli.cli_run.UiPath"), + patch( + "uipath._cli.cli_run.Workspace.create", + return_value=workspace, + ), + patch( + "uipath._cli.cli_run.WorkspaceHydrator", + side_effect=RuntimeError("construction failed"), + ), + ): + runner.invoke(cli, ["run", "main"]) + + workspace.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + def test_no_main_function_found( self, runner: CliRunner, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 3eba8c870..900c74bf7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.21" +version = "2.15.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2682,7 +2682,7 @@ requires-dist = [ { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-ipc", marker = "extra == 'ipc'", specifier = ">=2.5.1,<2.6.0" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.12.2,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, ] provides-extras = ["ipc"] @@ -2800,16 +2800,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.12.2" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/c0/d55cf48ee43758c2c1c65f52fd0596fd75f5bd5067a5016e6553b4d2c5fe/uipath_runtime-0.13.0.tar.gz", hash = "sha256:8ae3150df5fb0043210faacf39148789c1fb252c6a8d6bc27174c0d9ce7304f5", size = 243594, upload-time = "2026-08-04T11:19:04.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/dc/88/9518dcbeedaa8117e5fd3d0424c4a7354bae1f3ede1b2e3f48524e4b7efc/uipath_runtime-0.13.0-py3-none-any.whl", hash = "sha256:2a7c128cf14fdbef899b272ee5995da63baeb882acc904f39ef8f8f52bcb019f", size = 96349, upload-time = "2026-08-04T11:19:03.376Z" }, ] [[package]]