diff --git a/api/streaming.py b/api/streaming.py index ae8ebe9f..90c2a68c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1516,6 +1516,33 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex merged = previous_display[:] seen = {_message_identity(m) for m in merged} current_user_key = _message_identity({'role': 'user', 'content': msg_text}) + current_user_in_candidates = any( + _message_identity(m) == current_user_key for m in candidates + ) + current_user_already_checkpointed = bool( + merged and _message_identity(merged[-1]) == current_user_key + ) + if ( + current_user_key is not None + and not current_user_in_candidates + and not current_user_already_checkpointed + and any( + isinstance(m, dict) and m.get('role') in ('assistant', 'tool') + for m in candidates + ) + ): + # Some provider retry/fallback paths can return an assistant/tool delta + # without echoing the current user turn. In deferred session-save mode + # the prompt exists only in pending_user_message, so appending that delta + # directly would make the assistant bubble appear attached to the prior + # exchange and then clear the pending prompt. Materialize the current + # turn at the transcript boundary before the assistant/tool response. + current_user_msg = {'role': 'user', 'content': msg_text} + insert_at = 0 + while insert_at < len(candidates) and _is_context_compression_marker(candidates[insert_at]): + insert_at += 1 + candidates = candidates[:insert_at] + [current_user_msg] + candidates[insert_at:] + for msg in candidates: key = _message_identity(msg) if ( diff --git a/docs/pr-media/1787/issue-1787-transcript-order.png b/docs/pr-media/1787/issue-1787-transcript-order.png new file mode 100644 index 00000000..b08a2a3e Binary files /dev/null and b/docs/pr-media/1787/issue-1787-transcript-order.png differ diff --git a/tests/test_session_save_mode.py b/tests/test_session_save_mode.py index 32759574..66c3fe3d 100644 --- a/tests/test_session_save_mode.py +++ b/tests/test_session_save_mode.py @@ -130,3 +130,30 @@ def test_eager_checkpointed_user_is_not_duplicated_after_agent_result(): msg_text="repeat me", ) assert [m["role"] for m in merged] == ["user", "assistant"] + + +def test_deferred_turn_is_materialized_when_agent_returns_assistant_only_delta(): + merged = streaming._merge_display_messages_after_agent_result( + previous_display=[ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ], + previous_context=[ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ], + result_messages=[ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + {"role": "assistant", "content": "current answer"}, + ], + 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"]