@@ -1720,6 +1720,282 @@ def test_ai_message_with_media_citation(self):
17201720 assert source .page_number == "3"
17211721
17221722
1723+ class TestMapAiMessageToEvents :
1724+ """Tests for map_ai_message_to_events (full AIMessage, e.g. PII-masking enabled)."""
1725+
1726+ @pytest .mark .asyncio
1727+ async def test_returns_empty_list_for_ai_message_without_id (self ):
1728+ """Should return empty list when AIMessage has no id."""
1729+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1730+ msg = AIMessage (content = "hello" , id = None )
1731+
1732+ result = await mapper .map_event (msg )
1733+
1734+ assert result == []
1735+
1736+ @pytest .mark .asyncio
1737+ async def test_returns_empty_list_for_duplicate_id (self ):
1738+ """Should ignore AIMessage with an already-seen id."""
1739+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1740+ msg = AIMessage (content = "hello" , id = "msg-1" )
1741+
1742+ await mapper .map_event (msg )
1743+ result = await mapper .map_event (AIMessage (content = "again" , id = "msg-1" ))
1744+
1745+ assert result == []
1746+
1747+ @pytest .mark .asyncio
1748+ async def test_routes_full_ai_message_not_chunk (self ):
1749+ """map_event should route AIMessage (not AIMessageChunk) to map_ai_message_to_events."""
1750+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1751+ msg = AIMessage (content = "hello" , id = "msg-1" )
1752+
1753+ result = await mapper .map_event (msg )
1754+
1755+ # A proper AIMessage should be handled (not None), unlike HumanMessage
1756+ assert result is not None
1757+
1758+ @pytest .mark .asyncio
1759+ async def test_emits_start_and_end_events (self ):
1760+ """Should emit message start and end events for simple text response."""
1761+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1762+ msg = AIMessage (content = "Hello world" , id = "msg-1" )
1763+
1764+ result = await mapper .map_event (msg )
1765+
1766+ assert result is not None
1767+ start_event = result [0 ]
1768+ assert start_event .message_id == "msg-1"
1769+ assert start_event .start is not None
1770+ assert start_event .start .role == "assistant"
1771+ assert start_event .content_part is not None
1772+ assert start_event .content_part .start is not None
1773+
1774+ end_event = result [- 1 ]
1775+ assert end_event .end is not None
1776+ assert end_event .content_part is not None
1777+ assert end_event .content_part .end is not None
1778+
1779+ @pytest .mark .asyncio
1780+ async def test_emits_content_chunk_for_string_content (self ):
1781+ """Should emit text chunk events for plain string content."""
1782+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1783+ msg = AIMessage (content = "Hello!" , id = "msg-1" )
1784+
1785+ result = await mapper .map_event (msg )
1786+
1787+ assert result is not None
1788+ chunk_events = [
1789+ e
1790+ for e in result
1791+ if e .content_part is not None and e .content_part .chunk is not None
1792+ ]
1793+ assert len (chunk_events ) == 1
1794+ event = chunk_events [0 ]
1795+ assert event .content_part is not None
1796+ assert event .content_part .chunk is not None
1797+ assert event .content_part .chunk .data == "Hello!"
1798+
1799+ @pytest .mark .asyncio
1800+ async def test_emits_content_chunk_for_list_content (self ):
1801+ """Should emit text chunk events when content is list[dict] (PII-masking format)."""
1802+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1803+ msg = AIMessage (
1804+ content = [{"type" : "text" , "text" : "Hello Maxwell!" }],
1805+ id = "msg-1" ,
1806+ )
1807+
1808+ result = await mapper .map_event (msg )
1809+
1810+ assert result is not None
1811+ chunk_events = [
1812+ e
1813+ for e in result
1814+ if e .content_part is not None and e .content_part .chunk is not None
1815+ ]
1816+ assert len (chunk_events ) == 1
1817+ event = chunk_events [0 ]
1818+ assert event .content_part is not None
1819+ assert event .content_part .chunk is not None
1820+ assert event .content_part .chunk .data == "Hello Maxwell!"
1821+
1822+ @pytest .mark .asyncio
1823+ async def test_emits_no_chunk_for_empty_content (self ):
1824+ """Should emit only start and end events when content is empty."""
1825+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1826+ msg = AIMessage (content = "" , id = "msg-1" )
1827+
1828+ result = await mapper .map_event (msg )
1829+
1830+ assert result is not None
1831+ chunk_events = [
1832+ e
1833+ for e in result
1834+ if e .content_part is not None and e .content_part .chunk is not None
1835+ ]
1836+ assert len (chunk_events ) == 0
1837+ # Still has start and end
1838+ assert result [0 ].start is not None
1839+ assert result [- 1 ].end is not None
1840+
1841+ @pytest .mark .asyncio
1842+ async def test_no_end_event_when_has_tool_calls (self ):
1843+ """Should not emit message end event when tool calls are present."""
1844+ storage = create_mock_storage ()
1845+ storage .get_value .return_value = {}
1846+ mapper = UiPathChatMessagesMapper ("test-runtime" , storage )
1847+ msg = AIMessage (
1848+ content = "" ,
1849+ id = "msg-1" ,
1850+ tool_calls = [{"id" : "tool-1" , "name" : "search" , "args" : {}}],
1851+ )
1852+
1853+ result = await mapper .map_event (msg )
1854+
1855+ assert result is not None
1856+ end_events = [e for e in result if e .end is not None ]
1857+ assert len (end_events ) == 0
1858+
1859+ @pytest .mark .asyncio
1860+ async def test_emits_tool_call_start_events_when_has_tool_calls (self ):
1861+ """Should emit tool call start events for each tool call."""
1862+ storage = create_mock_storage ()
1863+ storage .get_value .return_value = {}
1864+ mapper = UiPathChatMessagesMapper ("test-runtime" , storage )
1865+ msg = AIMessage (
1866+ content = "" ,
1867+ id = "msg-1" ,
1868+ tool_calls = [{"id" : "tool-1" , "name" : "search" , "args" : {"query" : "cats" }}],
1869+ )
1870+
1871+ result = await mapper .map_event (msg )
1872+
1873+ assert result is not None
1874+ tool_start_events = [
1875+ e
1876+ for e in result
1877+ if e .tool_call is not None and e .tool_call .start is not None
1878+ ]
1879+ assert len (tool_start_events ) == 1
1880+ tool_event = tool_start_events [0 ]
1881+ assert tool_event .tool_call is not None
1882+ assert tool_event .tool_call .start is not None
1883+ assert tool_event .tool_call .tool_call_id == "tool-1"
1884+ assert tool_event .tool_call .start .tool_name == "search"
1885+ assert tool_event .tool_call .start .input == {"query" : "cats" }
1886+
1887+ @pytest .mark .asyncio
1888+ async def test_stores_tool_call_to_message_id_mapping (self ):
1889+ """Should persist tool_call_id -> message_id mapping in storage."""
1890+ storage = create_mock_storage ()
1891+ storage .get_value .return_value = {}
1892+ mapper = UiPathChatMessagesMapper ("test-runtime" , storage )
1893+ msg = AIMessage (
1894+ content = "" ,
1895+ id = "msg-1" ,
1896+ tool_calls = [{"id" : "tool-1" , "name" : "search" , "args" : {}}],
1897+ )
1898+
1899+ await mapper .map_event (msg )
1900+
1901+ storage .set_value .assert_called ()
1902+ call_args = storage .set_value .call_args [0 ]
1903+ assert call_args [2 ] == "tool_call_map"
1904+ assert call_args [3 ] == {"tool-1" : "msg-1" }
1905+
1906+ @pytest .mark .asyncio
1907+ async def test_tracks_seen_message_id (self ):
1908+ """Should add message id to seen_message_ids."""
1909+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1910+ msg = AIMessage (content = "hi" , id = "msg-42" )
1911+
1912+ await mapper .map_event (msg )
1913+
1914+ assert "msg-42" in mapper .seen_message_ids
1915+
1916+ @pytest .mark .asyncio
1917+ async def test_processes_citations_in_content (self ):
1918+ """Should strip citation tags, emit cleaned text, and attach citation to chunk."""
1919+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1920+ msg = AIMessage (
1921+ content = 'Some fact<uip:cite title="Doc" url="https://doc.com" /> and more.' ,
1922+ id = "msg-1" ,
1923+ )
1924+
1925+ result = await mapper .map_event (msg )
1926+
1927+ assert result is not None
1928+ chunk_events = [
1929+ e
1930+ for e in result
1931+ if e .content_part is not None and e .content_part .chunk is not None
1932+ ]
1933+ texts : list [str ] = []
1934+ for e in chunk_events :
1935+ assert e .content_part is not None
1936+ assert e .content_part .chunk is not None
1937+ assert e .content_part .chunk .data is not None
1938+ texts .append (e .content_part .chunk .data )
1939+ full_text = "" .join (texts )
1940+ assert "uip:cite" not in full_text
1941+ assert "Some fact" in full_text
1942+
1943+ # The "Some fact" chunk should carry an attached citation
1944+ citation_chunk = next (
1945+ e
1946+ for e in chunk_events
1947+ if e .content_part is not None
1948+ and e .content_part .chunk is not None
1949+ and e .content_part .chunk .citation is not None
1950+ )
1951+ assert citation_chunk .content_part is not None
1952+ assert citation_chunk .content_part .chunk is not None
1953+ citation_event = citation_chunk .content_part .chunk .citation
1954+ assert citation_event is not None
1955+ assert citation_event .end is not None
1956+ assert len (citation_event .end .sources ) == 1
1957+ source = citation_event .end .sources [0 ]
1958+ assert isinstance (source , UiPathConversationCitationSourceUrl )
1959+ assert source .url == "https://doc.com"
1960+ assert source .title == "Doc"
1961+
1962+ @pytest .mark .asyncio
1963+ async def test_pii_masked_response_full_flow (self ):
1964+ """End-to-end: PII-masked response arrives as single AIMessage with list content."""
1965+ mapper = UiPathChatMessagesMapper ("test-runtime" , None )
1966+ # Simulates the format returned by LLM-gateway with PII masking enabled
1967+ msg = AIMessage (
1968+ content = [
1969+ {
1970+ "type" : "text" ,
1971+ "text" : "Hello! Here's what I can do:\n \n 1. **Web Search**\n 2. **File Analysis**" ,
1972+ }
1973+ ],
1974+ id = "lc_run--019cbfe6-36b4-71d3-9988-d83569e6ffda-0" ,
1975+ )
1976+
1977+ result = await mapper .map_event (msg )
1978+
1979+ assert result is not None
1980+ assert result [0 ].start is not None
1981+ assert result [0 ].start .role == "assistant"
1982+ chunk_events = [
1983+ e
1984+ for e in result
1985+ if e .content_part is not None and e .content_part .chunk is not None
1986+ ]
1987+ assert len (chunk_events ) >= 1
1988+ texts : list [str ] = []
1989+ for e in chunk_events :
1990+ assert e .content_part is not None
1991+ assert e .content_part .chunk is not None
1992+ assert e .content_part .chunk .data is not None
1993+ texts .append (e .content_part .chunk .data )
1994+ full_text = "" .join (texts )
1995+ assert "Hello!" in full_text
1996+ assert result [- 1 ].end is not None
1997+
1998+
17231999class TestConfirmationToolDeferral :
17242000 """Tests for deferring startToolCall events for confirmation tools."""
17252001
0 commit comments