From a25ee7525d5872d8fe82f5fdbd87b8fd58fca737 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:29:19 -0400 Subject: [PATCH 1/2] chore: snapshot in-flight voice Maestro flow work (pre End-Voice-Agent rework baseline) Baseline commit so the next change (End Voice Agent rework) starts from a clean working tree and a clear diff. Captures the current in-flight changes as-is. Co-Authored-By: Claude Opus 4.8 --- .../src/uipath/_cli/_chat/_voice_bridge.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index 6164b9f3d..c4f1bdf92 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -74,6 +74,12 @@ def __init__( self._done = asyncio.Event() self._in_flight: set[asyncio.Task[None]] = set() self._end_reason: VoiceSessionEndReason | None = None + self._end_detail: dict[str, Any] = {} + + @property + def end_detail(self) -> dict[str, Any]: + """Termination detail CAS forwarded over `voice_session_ended` (callSid, reason, terminationReason).""" + return self._end_detail async def run(self) -> VoiceSessionEndReason: """Connect, dispatch tool calls until session ends, then disconnect. @@ -206,8 +212,16 @@ async def _execute_tool_call(self, call: UiPathVoiceToolCallRequest) -> None: tool_result.is_error, ) - async def _handle_session_ended(self, _data: Any, *_: Any) -> None: - logger.info("[Voice] voice_session_ended received") + async def _handle_session_ended(self, data: Any = None, *_: Any) -> None: + detail = data if isinstance(data, dict) else {} + self._end_detail = detail + logger.info( + "[Voice] voice_session_ended received " + "(terminationReason=%s, reason=%s, callSid=%s)", + detail.get("terminationReason"), + detail.get("reason"), + detail.get("callSid"), + ) self._end_session(VoiceSessionEndReason.COMPLETED) From 8579ac2e4e9b859e581f480e839296e941adb7ce Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:08:26 -0400 Subject: [PATCH 2/2] chore(voice): document + test endedBy/callEnded in voice_session_ended end_detail The bridge already stores the CAS voice_session_ended payload opaquely, so the new endedBy/callEnded keys flow through untouched. Refresh the end_detail docstring and the session-ended log line to reflect them, and add a regression test that arbitrary end_detail keys are preserved for the job runtime to surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/uipath/_cli/_chat/_voice_bridge.py | 16 +++++++--- .../tests/cli/chat/test_voice_bridge.py | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index c4f1bdf92..75a6654af 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -78,7 +78,13 @@ def __init__( @property def end_detail(self) -> dict[str, Any]: - """Termination detail CAS forwarded over `voice_session_ended` (callSid, reason, terminationReason).""" + """Opaque termination detail CAS forwarded over `voice_session_ended`. + + Forwarded verbatim — arbitrary keys flow through untouched. CAS currently + sends `endedBy` ('agent'|'user'|'system'|'error'), `callEnded` (bool), and + an optional `reason`; older payloads carried `callSid`/`terminationReason`. + Not validated into a model; the job runtime curates what it surfaces as output. + """ return self._end_detail async def run(self) -> VoiceSessionEndReason: @@ -213,14 +219,16 @@ async def _execute_tool_call(self, call: UiPathVoiceToolCallRequest) -> None: ) async def _handle_session_ended(self, data: Any = None, *_: Any) -> None: + # Stored opaquely: arbitrary keys (endedBy, callEnded, reason, ...) flow + # through untouched for the job runtime to surface as output. detail = data if isinstance(data, dict) else {} self._end_detail = detail logger.info( "[Voice] voice_session_ended received " - "(terminationReason=%s, reason=%s, callSid=%s)", - detail.get("terminationReason"), + "(endedBy=%s, callEnded=%s, reason=%s)", + detail.get("endedBy"), + detail.get("callEnded"), detail.get("reason"), - detail.get("callSid"), ) self._end_session(VoiceSessionEndReason.COMPLETED) diff --git a/packages/uipath/tests/cli/chat/test_voice_bridge.py b/packages/uipath/tests/cli/chat/test_voice_bridge.py index b945fb6e3..da8a6050f 100644 --- a/packages/uipath/tests/cli/chat/test_voice_bridge.py +++ b/packages/uipath/tests/cli/chat/test_voice_bridge.py @@ -42,6 +42,35 @@ async def test_session_ended_sets_completed(self) -> None: await session._handle_session_ended(None) assert session._end_reason == VoiceSessionEndReason.COMPLETED + async def test_session_ended_preserves_payload_opaquely(self) -> None: + """Regression: arbitrary CAS keys (endedBy, callEnded, reason) survive untouched. + + The bridge forwards `end_detail` verbatim; the job runtime curates what it + surfaces. New contract keys must arrive in `end_detail` unmodified. + """ + session = _make_session() + payload = { + "conversationId": "conv-1", + "endedBy": "agent", + "callEnded": False, + "reason": "agent_completed", + "someFutureKey": {"nested": True}, + } + + await session._handle_session_ended(payload) + + assert session.end_detail == payload + assert session.end_detail["endedBy"] == "agent" + assert session.end_detail["callEnded"] is False + assert session._end_reason == VoiceSessionEndReason.COMPLETED + + async def test_session_ended_non_dict_payload_is_empty_detail(self) -> None: + """A non-dict payload (e.g. None) yields an empty detail, not a crash.""" + session = _make_session() + await session._handle_session_ended("not-a-dict") + assert session.end_detail == {} + assert session._end_reason == VoiceSessionEndReason.COMPLETED + async def test_disconnect_sets_disconnected(self) -> None: session = _make_session() await session._handle_disconnect()