Skip to content

Commit 67a8571

Browse files
authored
fix: handle non-streamed conversational AIMessage for pii-masking (#671)
1 parent 2db64e0 commit 67a8571

File tree

4 files changed

+319
-7
lines changed

4 files changed

+319
-7
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.9.13"
3+
version = "0.9.14"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/runtime/messages.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ def __init__(self, runtime_id: str, storage: UiPathRuntimeStorageProtocol | None
5858
"""Initialize the mapper with empty state."""
5959
self.runtime_id = runtime_id
6060
self.storage = storage
61+
self.current_message: AIMessageChunk | AIMessage
6162
self.tool_names_requiring_confirmation: set[str] = set()
62-
self.current_message: AIMessageChunk
6363
self.seen_message_ids: set[str] = set()
6464
self._storage_lock = asyncio.Lock()
6565
self._citation_stream_processor = CitationStreamProcessor()
@@ -256,10 +256,14 @@ async def map_event(
256256
Returns:
257257
A UiPathConversationMessageEvent if the message should be emitted, None otherwise.
258258
"""
259-
# --- Streaming AIMessageChunk ---
259+
# --- Streaming AIMessageChunk (check before AIMessage since it's a subclass) ---
260260
if isinstance(message, AIMessageChunk):
261261
return await self.map_ai_message_chunk_to_events(message)
262262

263+
# --- Full AIMessage (e.g. when PII-masking is enabled) ---
264+
if isinstance(message, AIMessage):
265+
return await self.map_ai_message_to_events(message)
266+
263267
# --- ToolMessage ---
264268
if isinstance(message, ToolMessage):
265269
return await self.map_tool_message_to_events(message)
@@ -335,8 +339,9 @@ async def map_ai_message_chunk_to_events(
335339
self._chunk_to_message_event(message.id, chunk)
336340
)
337341
case "tool_call_chunk":
338-
# Accumulate the message chunk
339-
self.current_message = self.current_message + message
342+
# Accumulate the message chunk. Note that we assume no interweaving of AIMessage and AIMessageChunks for a given message.
343+
if isinstance(self.current_message, AIMessageChunk):
344+
self.current_message = self.current_message + message
340345

341346
elif isinstance(message.content, str) and message.content:
342347
# Fallback: raw string content on the chunk (rare when using content_blocks)
@@ -362,6 +367,35 @@ async def map_ai_message_chunk_to_events(
362367

363368
return events
364369

370+
async def map_ai_message_to_events(
371+
self, message: AIMessage
372+
) -> list[UiPathConversationMessageEvent]:
373+
"""Handle a full AIMessage (non-streaming)."""
374+
if message.id is None or message.id in self.seen_message_ids:
375+
return []
376+
377+
self.seen_message_ids.add(message.id)
378+
self.current_message = message
379+
self._citation_stream_processor = CitationStreamProcessor()
380+
381+
events: list[UiPathConversationMessageEvent] = []
382+
events.append(self.map_to_message_start_event(message.id))
383+
384+
text = self._extract_text(message.content)
385+
if text:
386+
for chunk in self._citation_stream_processor.add_chunk(text):
387+
events.append(self._chunk_to_message_event(message.id, chunk))
388+
for chunk in self._citation_stream_processor.finalize():
389+
events.append(self._chunk_to_message_event(message.id, chunk))
390+
self._citation_stream_processor = CitationStreamProcessor()
391+
392+
if message.tool_calls:
393+
events.extend(await self.map_current_message_to_start_tool_call_events())
394+
else:
395+
events.append(self.map_to_message_end_event(message.id))
396+
397+
return events
398+
365399
async def map_current_message_to_start_tool_call_events(self):
366400
events: list[UiPathConversationMessageEvent] = []
367401
if (
@@ -532,7 +566,9 @@ def map_to_message_start_event(
532566
),
533567
content_part=UiPathConversationContentPartEvent(
534568
content_part_id=self.get_content_part_id(message_id),
535-
start=UiPathConversationContentPartStartEvent(mime_type="text/plain"),
569+
start=UiPathConversationContentPartStartEvent(
570+
mime_type="text/markdown"
571+
),
536572
),
537573
)
538574

tests/runtime/test_chat_message_mapper.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\n1. **Web Search**\n2. **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+
17231999
class TestConfirmationToolDeferral:
17242000
"""Tests for deferring startToolCall events for confirmation tools."""
17252001

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)