Skip to content

Commit c85613e

Browse files
valentinabojanValentina Bojanclaude
authored
fix: preserve AIMessage id in replace_tool_calls (#688)
Co-authored-by: Valentina Bojan <valentina.bojan@uipath.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 848f660 commit c85613e

File tree

4 files changed

+78
-3
lines changed

4 files changed

+78
-3
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.8.19"
3+
version = "0.8.20"
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/agent/messages/message_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ def replace_tool_calls(message: AIMessage, tool_calls: list[ToolCall]) -> AIMess
3737
content_blocks=content_blocks,
3838
tool_calls=tool_calls,
3939
response_metadata=response_metadata,
40+
id=message.id,
4041
)

tests/agent/messages/test_message_utils.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
"""Tests for agent/messages/message_utils.py module."""
22

3-
from langchain.messages import AIMessage, ToolCall
3+
from typing import Any, Union
4+
5+
from langchain.messages import AIMessage, HumanMessage, ToolCall
6+
from langchain_core.messages import BaseMessage
47
from langchain_core.messages.content import (
58
ContentBlock,
69
create_text_block,
710
create_tool_call,
811
)
12+
from langgraph.graph.message import add_messages
913

1014
from uipath_langchain.agent.messages.message_utils import replace_tool_calls
1115

16+
MessageItem = Union[BaseMessage, list[str], tuple[str, str], str, dict[str, Any]]
17+
1218

1319
class TestReplaceToolCalls:
1420
"""Test cases for replace_tool_calls function."""
@@ -179,6 +185,74 @@ def test_replace_tool_calls_no_original_metadata(self):
179185
assert len(tool_call_blocks) == 1
180186
assert tool_call_blocks[0]["name"] == "new_tool"
181187

188+
def test_replace_tool_calls_preserves_message_id(self):
189+
"""Test that the original message id is preserved after replacement."""
190+
original_tool_calls = [ToolCall(name="old_tool", args={}, id="old_id")]
191+
original_content_blocks: list[ContentBlock] = [
192+
create_text_block("Test message"),
193+
create_tool_call(name="old_tool", args={}, id="old_id"),
194+
]
195+
original_message = AIMessage(
196+
content_blocks=original_content_blocks,
197+
tool_calls=original_tool_calls,
198+
id="msg-original-id",
199+
)
200+
201+
new_tool_calls = [ToolCall(name="new_tool", args={}, id="new_id")]
202+
203+
result = replace_tool_calls(original_message, new_tool_calls)
204+
205+
assert result.id == "msg-original-id"
206+
207+
def test_replace_tool_calls_updated_args_visible_via_add_messages(self):
208+
"""Test that updated tool call args are visible after add_messages processes them.
209+
210+
Reproduces the HITL bug: when a human reviews and updates activity input
211+
during an escalation, the activity must execute with the reviewed args.
212+
Without id preservation, add_messages appends a duplicate AIMessage
213+
instead of replacing the original, causing the tool to run with stale args.
214+
"""
215+
original_tool_calls = [
216+
ToolCall(name="my_activity", args={"input": "original_value"}, id="call_1")
217+
]
218+
original_ai_message = AIMessage(
219+
content_blocks=[
220+
create_text_block("I will invoke the activity"),
221+
create_tool_call(
222+
name="my_activity", args={"input": "original_value"}, id="call_1"
223+
),
224+
],
225+
tool_calls=original_tool_calls,
226+
id="msg-from-llm",
227+
)
228+
229+
messages: list[MessageItem] = [
230+
HumanMessage(content="do something", id="msg-human"),
231+
original_ai_message,
232+
]
233+
234+
# Simulate HITL review: human changes the input
235+
reviewed_tool_calls = [
236+
ToolCall(name="my_activity", args={"input": "reviewed_value"}, id="call_1")
237+
]
238+
updated_ai_message = replace_tool_calls(
239+
original_ai_message, reviewed_tool_calls
240+
)
241+
242+
# Simulate what Command(update={"messages": [updated_ai_message]}) does
243+
new_messages: list[MessageItem] = [updated_ai_message]
244+
result_messages = add_messages(messages, new_messages)
245+
246+
# There must be exactly one AIMessage — not a duplicate
247+
ai_messages = [m for m in result_messages if isinstance(m, AIMessage)]
248+
assert len(ai_messages) == 1, (
249+
f"Expected 1 AIMessage but got {len(ai_messages)}; "
250+
"add_messages appended instead of replacing (id mismatch)"
251+
)
252+
253+
# The surviving AIMessage must carry the reviewed args
254+
assert ai_messages[0].tool_calls[0]["args"] == {"input": "reviewed_value"}
255+
182256
def test_replace_tool_calls_content_blocks(self):
183257
"""Test that non-tool content blocks are preserved."""
184258
original_tool_calls = [ToolCall(name="old_tool", args={}, id="old_id")]

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)