From f94398cd46358a86d41b9f3ee406f02c156f323a Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Mon, 15 Jun 2026 14:53:54 -0700 Subject: [PATCH 1/4] feat: allow conversational agents to optionally end exchange --- pyproject.toml | 2 +- src/uipath/runtime/chat/runtime.py | 7 +++- src/uipath/runtime/context.py | 5 +++ tests/test_chat.py | 64 ++++++++++++++++++++++++++++++ tests/test_context.py | 32 +++++++++++++++ uv.lock | 4 +- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1762d36..76db189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.0" +version = "0.12.0" 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 040040b..b2a0c71 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -35,16 +35,20 @@ def __init__( self, delegate: UiPathRuntimeProtocol, chat_bridge: UiPathChatProtocol, + end_exchange: bool = True, ): """Initialize the UiPathChatRuntime. Args: delegate: The underlying runtime to wrap chat_bridge: Bridge for chat event communication + end_exchange: Whether to emit the exchange end event. When False, the exchange is left open so a + downstream consumer can continue it and end it later. """ super().__init__() self.delegate = delegate self.chat_bridge = chat_bridge + self.end_exchange = end_exchange async def execute( self, @@ -167,7 +171,8 @@ async def stream( else: yield event execution_completed = True - await self.chat_bridge.emit_exchange_end_event() + if self.end_exchange: + await self.chat_bridge.emit_exchange_end_event() else: yield event diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 2fe81a8..567c638 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -37,6 +37,10 @@ class UiPathRuntimeContext(BaseModel): ) exchange_id: str | None = Field(None, description="Exchange identifier for CAS") message_id: str | None = Field(None, description="Message identifier for CAS") + end_exchange: bool = Field( + True, + description="Whether to emit the exchange end event for CAS", + ) voice_mode: Literal["session"] | None = Field( None, description="Voice job type for CAS" ) @@ -364,6 +368,7 @@ def from_config( "conversationalService.conversationId": "conversation_id", "conversationalService.exchangeId": "exchange_id", "conversationalService.messageId": "message_id", + "conversationalService.endExchange": "end_exchange", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", "voice.mode": "voice_mode", diff --git a/tests/test_chat.py b/tests/test_chat.py index c3be06b..c87b7cb 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -257,6 +257,70 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 +@pytest.mark.asyncio +async def test_chat_runtime_emits_exchange_end_by_default(): + """Without end_exchange specified, exchange end is emitted.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): + """end_exchange=True emits the exchange end event.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + end_exchange=True, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chat_runtime_skips_exchange_end_when_end_exchange_false(): + """end_exchange=False suppresses the exchange end event but completes normally.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello", "World"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + end_exchange=False, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + # Execution completes normally; only the exchange end emission is skipped + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 + cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited() + + @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" diff --git a/tests/test_context.py b/tests/test_context.py index 9231f65..7906954 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -241,6 +241,38 @@ def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None: assert ctx.mcp_server_slug == "test-server-slug" +def test_from_config_maps_end_exchange_fps_property(tmp_path: Path) -> None: + """conversationalService.endExchange should map onto end_exchange.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + "conversationalService.exchangeId": "ex-456", + "conversationalService.endExchange": False, + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is False + + +def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> None: + """end_exchange defaults to True (legacy behavior) when the fps key is missing.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is True + + def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None: runtime_dir = tmp_path / "runtime" ctx = UiPathRuntimeContext( diff --git a/uv.lock b/uv.lock index 8a9846e..fca7407 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-27T14:28:49.412753Z" +exclude-newer = "2026-06-10T16:51:23.374942Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From fb37dc9c2eaf5608d03719b84a33ed54a427a958 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Wed, 17 Jun 2026 10:33:41 -0700 Subject: [PATCH 2/4] fix: move emit end exchange check to bridge --- src/uipath/runtime/chat/runtime.py | 7 +---- tests/test_chat.py | 50 +++--------------------------- 2 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index b2a0c71..040040b 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -35,20 +35,16 @@ def __init__( self, delegate: UiPathRuntimeProtocol, chat_bridge: UiPathChatProtocol, - end_exchange: bool = True, ): """Initialize the UiPathChatRuntime. Args: delegate: The underlying runtime to wrap chat_bridge: Bridge for chat event communication - end_exchange: Whether to emit the exchange end event. When False, the exchange is left open so a - downstream consumer can continue it and end it later. """ super().__init__() self.delegate = delegate self.chat_bridge = chat_bridge - self.end_exchange = end_exchange async def execute( self, @@ -171,8 +167,7 @@ async def stream( else: yield event execution_completed = True - if self.end_exchange: - await self.chat_bridge.emit_exchange_end_event() + await self.chat_bridge.emit_exchange_end_event() else: yield event diff --git a/tests/test_chat.py b/tests/test_chat.py index c87b7cb..f252ebf 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -258,28 +258,12 @@ async def test_chat_runtime_stream_yields_all_events(): @pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_by_default(): - """Without end_exchange specified, exchange end is emitted.""" +async def test_chat_runtime_emits_exchange_end_on_success(): + """The runtime always emits the exchange end event on successful completion. - runtime_impl = StreamingMockRuntime(messages=["Hello"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() - - -@pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): - """end_exchange=True emits the exchange end event.""" + Whether that event is honored (e.g. suppressed to keep the exchange open) is a + decision for the bridge implementation, not this low-level runtime. + """ runtime_impl = StreamingMockRuntime(messages=["Hello"]) bridge = make_chat_bridge_mock() @@ -287,7 +271,6 @@ async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): chat_runtime = UiPathChatRuntime( delegate=runtime_impl, chat_bridge=bridge, - end_exchange=True, ) result = await chat_runtime.execute({}) @@ -298,29 +281,6 @@ async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() -@pytest.mark.asyncio -async def test_chat_runtime_skips_exchange_end_when_end_exchange_false(): - """end_exchange=False suppresses the exchange end event but completes normally.""" - - runtime_impl = StreamingMockRuntime(messages=["Hello", "World"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - end_exchange=False, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - # Execution completes normally; only the exchange end emission is skipped - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 - cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited() - - @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" From a9e4c3547aef1648c7868241c108005913a9a523 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Wed, 17 Jun 2026 10:35:58 -0700 Subject: [PATCH 3/4] fix: remove test --- tests/test_chat.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tests/test_chat.py b/tests/test_chat.py index f252ebf..c3be06b 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -257,30 +257,6 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 -@pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_on_success(): - """The runtime always emits the exchange end event on successful completion. - - Whether that event is honored (e.g. suppressed to keep the exchange open) is a - decision for the bridge implementation, not this low-level runtime. - """ - - runtime_impl = StreamingMockRuntime(messages=["Hello"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() - - @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" From 5af927e7a4bcf936cab2b382a1409e588740fcb2 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Fri, 19 Jun 2026 12:33:40 -0700 Subject: [PATCH 4/4] chore: patch increase to 0.11.1 --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76db189..3a7cb2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.0" +version = "0.11.1" 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/uv.lock b/uv.lock index fca7407..2f586a0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-10T16:51:23.374942Z" +exclude-newer = "2026-06-17T19:32:25.812022Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.0" +version = "0.11.1" source = { editable = "." } dependencies = [ { name = "uipath-core" },