NousResearch/hermes-agent

Duplicate transcript on cold resume with rotating compression

Cluster 68196 · agent. Data as of

Nothing open on GitHub. Fix merged in #68205 on 21 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #68205. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #68205. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address a bug where persisted transcript content is duplicated during a cold desktop resume when rotating preflight compression is active. The work focuses on preventing re-appending already-persisted transcript data in that specific scenario.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #68205 fix(agent): don't re-append persisted transcript on cold-resume rotating compression (#68196)

    merged to main

    files: agent/conversation_compression.py, tests/agent/test_rotation_flush_persisted_boundary_68196.py

    Show the diff
    • agent/conversation_compression.py
      @@ -1236,8 +1236,29 @@ def _release_lock() -> None:
                           # Flush any un-persisted current-turn messages to the OLD
                           # session before ending it, so they survive in the preserved
                           # parent transcript (#47202). (In-place skips this — see above.)
      +                    #
      +                    # Pass the already-durable prefix as conversation_history so
      +                    # the flush skips it by identity (#68196). Preflight
      +                    # compression runs BEFORE the normal turn flush has stamped
      +                    # the cold-resumed history dicts with _DB_PERSISTED_MARKER, so
      +                    # without a boundary _flush_messages_to_session_db treats every
      +                    # restored row as new and re-appends the whole transcript to
      +                    # the parent. turn_context anchors _persist_user_message_idx at
      +                    # the current-turn user message before preflight runs, so
      +                    # messages[:idx] is exactly the persisted prefix; only the
      +                    # current turn's new messages get written.
      +                    current_idx = getattr(agent, "_persist_user_message_idx", None)
      +                    persisted_history = (
      +                        messages[:current_idx]
      +                        if isinstance(current_idx, int)
      +                        and 0 <= current_idx <= len(messages)
      +                        else None
      +                    )
                           try:
      -                        agent._flush_messages_to_session_db(messages)
      +                        agent._flush_messages_to_session_db(
      +                            messages,
      +                            conversation_history=persisted_history,
      +                        )
                           except Exception:
                               pass  # best-effort — don't block compression on a flush error
                           # Propagate title to the new session with auto-numbering
    • tests/agent/test_rotation_flush_persisted_boundary_68196.py
      @@ -0,0 +1,118 @@
      +"""Regression (#68196): rotating preflight compression must not re-append the
      +already-persisted transcript to the parent session on cold resume.
      +
      +On the first turn after a cold Desktop resume, the stored rows are handed to
      +``run_conversation()`` as ``conversation_history`` and live in the message list
      +as plain dicts that have NOT yet been stamped with ``_DB_PERSISTED_MARKER`` —
      +the normal turn flush (which stamps them) runs *after* preflight compression.
      +
      +The legacy rotation branch in ``agent/conversation_compression.py`` flushes the
      +current turn to the OLD session before ending it (#47202). It used to call
      +``agent._flush_messages_to_session_db(messages)`` with no history boundary, so
      +``_flush_messages_to_session_db`` saw an empty ``history_ids`` set and treated
      +every restored row as new — durably appending the whole transcript to the
      +parent a second time. The fix passes ``messages[:_persist_user_message_idx]``
      +(the already-durable prefix ``turn_context`` anchors before preflight runs) as
      +``conversation_history`` so only the current turn's new messages are written.
      +
      +Without the fix the parent grows to 5 rows (the two originals + a duplicate of
      +both + the new turn). With it the parent holds exactly the two originals plus
      +the single new turn.
      +"""
      +
      +from __future__ import annotations
      +
      +import os
      +import time
      +from pathlib import Path
      +from unittest.mock import MagicMock, patch
      +
      +from hermes_state import SessionDB
      +
      +
      +def _build_agent_with_db(db: SessionDB, session_id: str):
      +    """Build an AIAgent wired to ``db`` and pinned to ``session_id``.
      +
      +    Mirrors the helper in ``test_compression_concurrent_fork.py``: stub the
      +    compressor so it returns deterministic output without an LLM call, and pin
      +    ``compression_in_place=False`` so the legacy rotation path is exercised
      +    regardless of the global default.
      +    """
      +    with patch.dict(os.environ, {"OPENROUTER_API_KEY": "[redacted:secret-assignment]"}):
      +        from run_agent import AIAgent
      +
      +        agent = AIAgent(
      +            api_key="[redacted:secret-assignment]",
      +            base_url="https://openrouter.ai/api/v1",
      +            model="test/model",
      +            quiet_mode=True,
      +            session_db=db,
      +            session_id=session_id,
      +            skip_context_files=True,
      +            skip_memory=True,
      +        )
      +
      +    compressor = MagicMock()
      +
      +    def _compress(*_a, **_kw):
      +        time.sleep(0.01)
      +        return [
      +            {"role": "user", "content": "[CONTEXT COMPACTION] summary"},
      +            {"role": "user", "content": "tail"},
      +        ]
      +
      +    compressor.compress.side_effect = _compress
      +    compressor.compression_count = 1
      +    compressor.last_prompt_tokens = 0
      +    compressor.last_completion_tokens = 0
      +    compressor._last_summary_error = None
      +    compressor._last_compress_aborted = False
      +    compressor._last_aux_model_failure_model = None
      +    compressor._last_aux_model_failure_error = None
      +    agent.context_compressor = compressor
      +    agent.compression_in_place = False
      +    return agent
      +
      +
      +def _contents(rows):
      +    return [r.get("content") for r in rows]
      +
      +
      +def test_rotation_flush_does_not_duplicate_persisted_prefix(tmp_path: Path) -> None:
      +    """Cold-resume + rotating preflight compression keeps the parent transcript
      +    at (persisted prefix + one new turn) — no second copy of the durable rows."""
      +    db = SessionDB(db_path=tmp_path / "state.db")
      +
      +    parent_sid = "COLD_RESUME_PARENT"
      +    db.create_session(parent_sid, source="desktop")
      +
      +    # Two durable rows already in the parent.
      +    db.append_message(parent_sid, "user", "persisted question")
      +    db.append_message(parent_sid, "assistant", "persisted answer")
      +
      +    # Cold resume: the stored rows come back as plain dicts, unstamped, and the
      +    # live turn appends one new user message on top.
      +    loaded = db.get_messages_as_conversation(parent_sid)
      +    assert _contents(loaded) == ["persisted question", "persisted answer"]
      +    messages = [*loaded, {"role": "user", "content": "new turn"}]
      +
      +    agent = _build_agent_with_db(db, parent_sid)
      +    # turn_context anchors this at the current-turn user message before preflight
      +    # compression runs; emulate that anchor.
      +    agent._persist_user_message_idx = len(messages) - 1
      +
      +    agent._compress_context(messages, "sys", approx_tokens=[redacted:secret-assignment]
      +
      +    # The flush at the rotation boundary lands on the OLD (parent) session,
      +    # which is then ended. Read it back verbatim (include_inactive to be robust
      +    # to the end_session bookkeeping).
      +    parent_rows = db.get_messages_as_conversation(parent_sid, include_inactive=True)
      +    contents = _contents(parent_rows)
      +
      +    assert contents.count("persisted question") == 1, (
      +        "Rotation flush re-appended the already-persisted prefix to the parent "
      +        f"(#68196). Parent transcript is {contents!r}; expected the two durable "
      +        "rows plus only the new turn."
      +    )
      +    assert contents.count("persisted answer") == 1
      +    assert contents == ["persisted question", "persisted answer", "new turn"]

1 PR closed without merging

From the close comments: duplicate 1

  • #68288 fix(agent/comp/desktop): prevent duplicate transcript rows on cold resume + rotating compression · duplicate → #68205
Closed issues · 1
  • Closed#68196Cold Desktop resume + rotating preflight compression duplicates persisted transcript
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#68205
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
21 Jul 2026

For your agent

The same facts, typed.