Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- Fixed duplicate assistant messages in browser chat transcripts by ignoring adjacent assistant replay duplicates during server-side result merge while preserving identical assistant text across separate user turns (#2051).

## [v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse)

### Added
Expand Down
12 changes: 12 additions & 0 deletions api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,18 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
# in result_messages, keep the durable checkpoint and append only
# the assistant/tool delta.
continue
if (
key is not None
and isinstance(msg, dict)
and msg.get('role') == 'assistant'
and merged
and _message_identity(merged[-1]) == key
):
# Some provider/result replay paths can include the same assistant
# message twice in the current delta. Treat only adjacent identity
# matches as replay duplicates so identical answers in separate
# user turns remain visible.
continue
if _is_context_compression_marker(msg) and key is not None and key in seen:
continue
display_msg = msg
Expand Down
63 changes: 63 additions & 0 deletions tests/test_session_save_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,69 @@ def test_deferred_turn_is_materialized_when_agent_returns_assistant_only_delta()
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]


def test_duplicate_assistant_delta_is_not_persisted_twice():
"""Provider/result merge replay must not duplicate the same assistant bubble."""
previous_display = [
{"role": "user", "content": "older prompt"},
{"role": "assistant", "content": "older answer"},
]
previous_context = list(previous_display)
result_messages = previous_context + [
{"role": "user", "content": "latest prompt"},
{"role": "assistant", "content": "current answer"},
{"role": "assistant", "content": "current answer"},
]

merged = streaming._merge_display_messages_after_agent_result(
previous_display=previous_display,
previous_context=previous_context,
result_messages=result_messages,
msg_text="latest prompt",
)

assert [m["role"] for m in merged] == [
"user",
"assistant",
"user",
"assistant",
]
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
assert (
sum(
1
for m in merged
if m.get("role") == "assistant" and m.get("content") == "current answer"
)
== 1
)


def test_same_assistant_text_across_different_turns_is_preserved():
previous_display = [
{"role": "user", "content": "first prompt"},
{"role": "assistant", "content": "same answer"},
]
previous_context = list(previous_display)
result_messages = previous_context + [
{"role": "user", "content": "second prompt"},
{"role": "assistant", "content": "same answer"},
]

merged = streaming._merge_display_messages_after_agent_result(
previous_display=previous_display,
previous_context=previous_context,
result_messages=result_messages,
msg_text="second prompt",
)

assert [m["content"] for m in merged] == [
"first prompt",
"same answer",
"second prompt",
"same answer",
]


def test_llm_title_generated_survives_save_and_load(_isolate_state):
s = Session(
session_id="generated_title",
Expand Down
Loading