Skip to content

Commit 55011b7

Browse files
moonbox3CopilotCopilot
authored
Python: Fix _deduplicate_messages catch-all branch dropping valid repeated messages (microsoft#4716)
* Fix _deduplicate_messages catch-all branch dropping valid repeated messages (microsoft#4682) Remove the catch-all dedup branch that used (role, hash(content_str)) as a dedup key. This incorrectly treated any two messages with the same role and identical content as duplicates, dropping valid repeated messages (e.g., a user saying 'yes' to confirm two separate things). The tool-specific dedup branches (tool results by call_id, assistant tool calls by call_id tuple) remain unchanged as they correctly identify true protocol-level duplicates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: consecutive-duplicate detection for non-tool messages (microsoft#4682) - Replace blanket dedup removal with consecutive-duplicate detection: only skip a message if the immediately preceding message has the same role and content, preserving protection against upstream replays while allowing identical messages at different conversation points. - Strengthen test assertions to verify message identity and order, not just list length. - Add tests for consecutive duplicate skipping, non-consecutive preservation, and messages with contents=None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Use message_id for deduplication instead of content hashing Deduplicate general messages by message_id when available, replacing the consecutive-duplicate content check. Two messages with the same id are definitively the same message (upstream replay), while identical content with distinct ids (e.g. repeated "yes" confirmations) is preserved. Messages without a message_id are always kept. * Fix message_id dedup: truthy check, content-hash fallback, log safety - Use truthy check (`if msg.message_id`) instead of `is not None` so empty-string IDs fall through to content-hash dedup rather than collapsing unrelated messages. - Add content-hash fallback for messages without message_id, preventing false negatives from integrations that don't set IDs. - Remove raw message_id from log format string (addresses log-injection surface with control characters). - Add tests for empty-string message_id edge cases. - Update existing tests to reflect content-hash dedup behavior. Fixes microsoft#4682 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf0af17 commit 55011b7

2 files changed

Lines changed: 132 additions & 5 deletions

File tree

python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,16 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]:
242242
unique_messages.append(msg)
243243

244244
else:
245-
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
246-
key = (role_value, hash(content_str))
245+
# Use message_id for deduplication when available — two messages with the
246+
# same id are definitively the same message (e.g. upstream replays), while
247+
# different messages that happen to share identical content (e.g. repeated
248+
# "yes" confirmations) will have distinct ids and be preserved.
249+
# Fall back to content-hash when message_id is absent or empty.
250+
if msg.message_id:
251+
key = ("id", msg.message_id)
252+
else:
253+
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
254+
key = ("content", role_value, hash(content_str))
247255

248256
if key in seen_keys:
249257
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")

python/packages/ag-ui/tests/ag_ui/test_message_adapters.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,15 +1015,111 @@ def test_deduplicate_assistant_tool_calls():
10151015
assert len(result) == 1
10161016

10171017

1018-
def test_deduplicate_general_messages():
1019-
"""Duplicate general user messages are deduplicated."""
1018+
def test_deduplicate_by_message_id():
1019+
"""Messages with the same message_id are deduplicated."""
10201020
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
10211021

10221022
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1023+
msg1.message_id = "msg-1"
10231024
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
1025+
msg2.message_id = "msg-1"
10241026

10251027
result = _deduplicate_messages([msg1, msg2])
10261028
assert len(result) == 1
1029+
assert result == [msg1]
1030+
1031+
1032+
def test_deduplicate_preserves_repeated_confirmations_with_distinct_ids():
1033+
"""Identical content with different message_ids is preserved."""
1034+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1035+
1036+
assistant = Message(role="assistant", contents=[Content.from_text(text="Are you sure?")])
1037+
assistant.message_id = "msg-1"
1038+
confirm1 = Message(role="user", contents=[Content.from_text(text="yes")])
1039+
confirm1.message_id = "msg-2"
1040+
confirm2 = Message(role="user", contents=[Content.from_text(text="yes")])
1041+
confirm2.message_id = "msg-3"
1042+
1043+
result = _deduplicate_messages([confirm1, assistant, confirm2])
1044+
assert result == [confirm1, assistant, confirm2]
1045+
1046+
1047+
def test_deduplicate_preserves_repeated_system_messages_with_distinct_ids():
1048+
"""Non-consecutive identical system messages with different ids are preserved."""
1049+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1050+
1051+
sys1 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
1052+
sys1.message_id = "msg-1"
1053+
user_msg = Message(role="user", contents=[Content.from_text(text="Hi")])
1054+
user_msg.message_id = "msg-2"
1055+
sys2 = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
1056+
sys2.message_id = "msg-3"
1057+
1058+
result = _deduplicate_messages([sys1, user_msg, sys2])
1059+
assert result == [sys1, user_msg, sys2]
1060+
1061+
1062+
def test_deduplicate_skips_replayed_system_messages_with_same_id():
1063+
"""System messages replayed with the same message_id are deduplicated."""
1064+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1065+
1066+
msgs = []
1067+
for _ in range(3):
1068+
m = Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")])
1069+
m.message_id = "msg-1"
1070+
msgs.append(m)
1071+
1072+
result = _deduplicate_messages(msgs)
1073+
assert len(result) == 1
1074+
1075+
1076+
def test_deduplicate_without_message_id_uses_content_hash():
1077+
"""Messages without message_id are deduplicated by content hash."""
1078+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1079+
1080+
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1081+
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
1082+
1083+
result = _deduplicate_messages([msg1, msg2])
1084+
assert result == [msg1]
1085+
1086+
1087+
def test_deduplicate_without_message_id_preserves_different_content():
1088+
"""Messages without message_id but different content are preserved."""
1089+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1090+
1091+
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1092+
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
1093+
1094+
result = _deduplicate_messages([msg1, msg2])
1095+
assert result == [msg1, msg2]
1096+
1097+
1098+
def test_deduplicate_handles_none_contents():
1099+
"""Messages with contents=None pass through without errors; duplicates are deduped."""
1100+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1101+
1102+
msg1 = Message(role="user", contents=None)
1103+
msg2 = Message(role="assistant", contents=[Content.from_text(text="Hello")])
1104+
msg3 = Message(role="user", contents=None)
1105+
1106+
result = _deduplicate_messages([msg1, msg2, msg3])
1107+
assert result == [msg1, msg2]
1108+
1109+
1110+
def test_deduplicate_mixed_id_and_no_id():
1111+
"""Messages with and without message_id coexist correctly."""
1112+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1113+
1114+
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1115+
msg1.message_id = "msg-1"
1116+
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")]) # no id
1117+
msg3 = Message(role="user", contents=[Content.from_text(text="Hello")])
1118+
msg3.message_id = "msg-1" # duplicate of msg1
1119+
1120+
result = _deduplicate_messages([msg1, msg2, msg3])
1121+
assert len(result) == 2
1122+
assert result == [msg1, msg2]
10271123

10281124

10291125
def test_deduplicate_replaces_empty_tool_result():
@@ -1038,7 +1134,30 @@ def test_deduplicate_replaces_empty_tool_result():
10381134
assert result[0].contents[0].result == "actual result"
10391135

10401136

1041-
# ── Multimodal & content conversion edge cases ──
1137+
def test_deduplicate_empty_string_message_id_falls_back_to_content_hash():
1138+
"""Empty-string message_id is treated as missing; content-hash dedup is used."""
1139+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1140+
1141+
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1142+
msg1.message_id = ""
1143+
msg2 = Message(role="user", contents=[Content.from_text(text="World")])
1144+
msg2.message_id = ""
1145+
1146+
result = _deduplicate_messages([msg1, msg2])
1147+
assert result == [msg1, msg2], "Different content with empty IDs should both be preserved"
1148+
1149+
1150+
def test_deduplicate_empty_string_message_id_deduplicates_same_content():
1151+
"""Empty-string message_id with identical content should be deduplicated."""
1152+
from agent_framework_ag_ui._message_adapters import _deduplicate_messages
1153+
1154+
msg1 = Message(role="user", contents=[Content.from_text(text="Hello")])
1155+
msg1.message_id = ""
1156+
msg2 = Message(role="user", contents=[Content.from_text(text="Hello")])
1157+
msg2.message_id = ""
1158+
1159+
result = _deduplicate_messages([msg1, msg2])
1160+
assert result == [msg1], "Same content with empty IDs should be deduplicated"
10421161

10431162

10441163
def test_convert_agui_content_unknown_source_type_fallback():

0 commit comments

Comments
 (0)