diff --git a/CHANGELOG.md b/CHANGELOG.md index b51faadd..234cdcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **fix(sessions): preserve distinct retried messages when merging transcripts** ([#2027](https://github.com/nesquena/hermes-webui/issues/2027)). Messaging session transcript merges now use `id`/`message_id` when present before falling back to the legacy role/content/timestamp/tool metadata key, so repeated turns with identical visible text are not silently collapsed. + ## [v0.51.40] — 2026-05-11 — Release P (4-PR contributor batch — quota subprocess hardening + env-lock prewarm + cron one-shot warning + Xiaomi env key) ### Fixed @@ -22,7 +26,6 @@ - 4 PRs from 3 different authors. `api/providers.py` was touched by #2030 (+110/-7 in quota probe path) and #2034 (+1 in `_PROVIDER_ENV_VAR` map) with disjoint hunks. `CHANGELOG.md` Unreleased section was the only true conflict (#2033 + #2034 both added bullets); resolved by keeping both entries. Stage merge otherwise clean. - ## [v0.51.39] — 2026-05-10 — Release O (4-PR contributor batch — Railway docker fix + Stop-button race + provider resolver + live context tracking) ### Fixed diff --git a/api/routes.py b/api/routes.py index f825d77c..bd3486bf 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3081,13 +3081,18 @@ def handle_get(handler, parsed) -> bool: str(m.get("role") or ""), str(m.get("content") or ""), )): - key = ( - str(msg.get("role") or ""), - str(msg.get("content") or ""), - str(msg.get("timestamp") or ""), - str(msg.get("tool_call_id") or ""), - str(msg.get("tool_name") or msg.get("name") or ""), - ) + message_identity = msg.get("id") or msg.get("message_id") + if message_identity: + key = ("message_id", str(message_identity)) + else: + key = ( + "legacy", + str(msg.get("role") or ""), + str(msg.get("content") or ""), + str(msg.get("timestamp") or ""), + str(msg.get("tool_call_id") or ""), + str(msg.get("tool_name") or msg.get("name") or ""), + ) if key in seen_message_keys: continue seen_message_keys.add(key) diff --git a/tests/test_session_lineage_full_transcript.py b/tests/test_session_lineage_full_transcript.py index 7efc6d18..63cdd203 100644 --- a/tests/test_session_lineage_full_transcript.py +++ b/tests/test_session_lineage_full_transcript.py @@ -59,3 +59,67 @@ def test_session_endpoint_merges_sidecar_and_lineage_messages_for_cli_sessions(m "tip assistant", "sidecar tail", ] + + +def test_session_endpoint_preserves_distinct_messages_with_different_ids(monkeypatch): + class DummySession: + def __init__(self): + self.messages = [ + { + "id": "sidecar-retry", + "role": "user", + "content": "retry the same request", + "timestamp": 2.0, + } + ] + self.tool_calls = [] + self.active_stream_id = None + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.context_length = 0 + self.threshold_tokens = 0 + self.last_prompt_tokens = 0 + self.model = "openai/gpt-5" + self.session_id = "tip" + + def compact(self): + return {"session_id": "tip", "title": "Tip", "model": "openai/gpt-5"} + + captured = {} + + monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: DummySession()) + monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda s: None) + monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {"session_source": "messaging"}) + monkeypatch.setattr(routes, "_is_messaging_session_record", lambda s: True) + monkeypatch.setattr( + routes, + "get_cli_session_messages", + lambda sid: [ + {"role": "user", "content": "root user", "timestamp": 1.0}, + { + "id": "cli-retry", + "role": "user", + "content": "retry the same request", + "timestamp": 2.0, + }, + ], + ) + monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda s: getattr(s, "model", None)) + monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda s: None) + monkeypatch.setattr(routes, "_merge_cli_sidebar_metadata", lambda raw, meta: raw) + monkeypatch.setattr(routes, "redact_session_data", lambda raw: raw) + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: captured.setdefault("payload", payload)) + + class Handler: + pass + + class Parsed: + path = "/api/session" + query = "session_id=tip" + + routes.handle_get(Handler(), Parsed()) + + session = captured["payload"]["session"] + retry_messages = [m for m in session["messages"] if m.get("content") == "retry the same request"] + assert [m.get("id") for m in retry_messages] == ["cli-retry", "sidecar-retry"]