diff --git a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py index 6164b9f3d..75a6654af 100644 --- a/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py +++ b/packages/uipath/src/uipath/_cli/_chat/_voice_bridge.py @@ -74,6 +74,18 @@ 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]: + """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: """Connect, dispatch tool calls until session ends, then disconnect. @@ -206,8 +218,18 @@ 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: + # 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 " + "(endedBy=%s, callEnded=%s, reason=%s)", + detail.get("endedBy"), + detail.get("callEnded"), + detail.get("reason"), + ) 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()