From a10531965f1038e50f3ce2a660144433a28204eb Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 7 Aug 2026 08:40:26 +0700 Subject: [PATCH 1/4] fix(agent): stop reference-only compaction handoff from becoming the active turn After a completed assistant stop, a standalone CONTEXT COMPACTION handoff could occupy the sole user slot and resume stale Historical Task Snapshot work with no new human ask. Guard post-compaction continues, hide standalone handoffs from session dispatch, and harden SUMMARY_PREFIX for the empty-after-handoff case (#80622). --- agent/context_compressor.py | 126 ++++++++++++++++++++++++++++++++- agent/conversation_loop.py | 102 ++++++++++++++++++++++++++ agent/turn_context.py | 18 +++-- run_agent.py | 20 +++++- tui_gateway/methods_session.py | 13 ++-- tui_gateway/methods_tools.py | 16 +++-- 6 files changed, 275 insertions(+), 20 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 0c9cd3c85fea..ea39c0b0610c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -106,6 +106,11 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: "Respond ONLY to the latest user message that appears AFTER this " "summary — that message is the single source of truth for what to do " "right now. " + "If no user message appears AFTER this summary, do nothing: do not " + "resume, wrap up, or continue work from " + f"'{HISTORICAL_TASK_HEADING}' or any other section, do not call tools, " + "and wait for a new user message. This handoff must never become the " + "active turn by itself. " "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " @@ -260,7 +265,38 @@ def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None: # written by that build generation; prepend only. tests/agent/ # test_summary_prefix_semantics.py byte-pins every entry to enforce this. _HISTORICAL_SUMMARY_PREFIXES = ( - # Pre-#69619: identical to the current prefix except the stale-item + # Pre-#80622: identical to the current prefix except it lacked the + # explicit "if no user message appears AFTER this summary, do nothing" + # clause. Standalone reference handoffs persisted by that build could + # occupy the active user slot after a completed assistant stop and + # resume stale Historical Task Snapshot work. + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " + "into the summary below. This is a handoff from a previous context " + "window — treat it as background reference, NOT as active instructions. " + "Do NOT answer questions or fulfill requests mentioned in this summary; " + "they were already addressed. " + "Respond ONLY to the latest user message that appears AFTER this " + "summary — that message is the single source of truth for what to do " + "right now. " + "Topic overlap with the summary does NOT mean you should resume its " + "task: even on similar topics, the latest user message WINS. Treat ONLY " + "the latest message as the active task and discard stale items from " + "'## Historical Task Snapshot' entirely — do not 'wrap up' or " + "'finish' work described there unless the latest message explicitly " + "asks for it. " + "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " + "back', 'just verify', 'don't do that anymore', 'never mind', a new " + "topic) must immediately end any in-flight work described in the " + "summary; do not re-surface it in later turns. " + "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system " + "prompt is ALWAYS authoritative and active — never ignore or deprioritize " + "memory content due to this compaction note. " + "None of the above restricts HOW you work: your tools remain fully " + "active — keep calling them normally for the active task (edit files, " + "run commands, search) instead of merely narrating what you would do. " + "The current session state (files, config, etc.) may reflect work " + "described here — avoid repeating it:", + # Pre-#69619: identical to the then-current prefix except the stale-item # discard clause named all four historical headings (the three # section headers removed by #69619 were still in the template). # Summaries persisted by builds immediately before #69619 carry this @@ -6919,3 +6955,91 @@ def is_compaction_summary_message(message: Any) -> bool: else: content = message return ContextCompressor._is_context_summary_content(content) + + +def _handoff_carries_live_user_content(message: Any) -> bool: + """Return True when a summary-bearing row still carries a live user ask. + + Merge-into-tail carriers preserve prior turn content before the summary. + Force-user-leading merges prepend the handoff + end marker to the real + ask, leaving a non-empty remainder after ``_SUMMARY_END_MARKER``. Either + shape must remain actionable (#80622 must not treat them as sole-handoff). + """ + if not isinstance(message, dict): + return False + content = message.get("content") + kind = ContextCompressor.classify_summary_content(content) + if kind == "merged": + return True + text = _content_text_for_contains(content) + marker_idx = text.find(_SUMMARY_END_MARKER) + if marker_idx < 0: + return False + return bool(text[marker_idx + len(_SUMMARY_END_MARKER) :].strip()) + + +def reference_handoff_would_drive_next_model_call( + messages: Optional[List[Dict[str, Any]]], +) -> bool: + """Return True when the next model call would be driven only by a handoff. + + A reference-only compaction handoff must never become the active user turn + by itself after an assistant response has already completed (#80622). Mid + tool-loop compression remains allowed: tool results / assistant tool_calls + after the handoff mean the loop is continuing an in-flight exchange, not + starting a fresh turn from the synthetic summary. + """ + if not messages: + return False + + last_driving_handoff = -1 + for index, message in enumerate(messages): + if not is_compaction_summary_message(message): + continue + if _handoff_carries_live_user_content(message): + # Embedded live ask — this row is not a sole-handoff driver. + continue + last_driving_handoff = index + + if last_driving_handoff < 0: + return False + + for message in messages[last_driving_handoff + 1 :]: + if not isinstance(message, dict): + continue + role = message.get("role") + if role == "tool": + return False + if role == "assistant" and message.get("tool_calls"): + return False + if ( + ContextCompressor._is_actionable_user_turn(message) + and not ContextCompressor._is_synthetic_compression_user_turn(message) + ): + return False + if is_compaction_summary_message(message) and _handoff_carries_live_user_content( + message + ): + return False + return True + + +def is_user_originated_turn(message: Any) -> bool: + """Return True for human-authored user turns (not compaction scaffolding). + + Gateway/session dispatchers (retry, undo, active-turn selection) must use + this instead of ``role == "user" and not display_kind`` — standalone + handoffs with ``_compressed_summary_has_user_turn`` were previously left + without ``display_kind=hidden`` and could be mistaken for real asks (#80622). + Summary-bearing rows are never user-originated, even when they embed a + live ask after the end marker (callers that need that text should unwrap). + """ + if not isinstance(message, dict) or message.get("role") != "user": + return False + if message.get("display_kind"): + return False + if is_compaction_summary_message(message): + return False + if ContextCompressor._is_synthetic_compression_user_turn(message): + return False + return ContextCompressor._is_actionable_user_turn(message) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cfd5717c474e..67c15b85d3bf 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -94,6 +94,70 @@ logger = logging.getLogger(__name__) + +def _restore_user_after_reference_handoff( + messages: List[Dict[str, Any]], user_message: Any +) -> bool: + """Re-append this turn's real user ask when compaction left only a handoff. + + Returns True when a restore append happened. Used before deciding whether + a post-compaction ``continue`` would let the reference-only summary drive + the next model call (#80622). + """ + from agent.context_compressor import reference_handoff_would_drive_next_model_call + + if not reference_handoff_would_drive_next_model_call(messages): + return False + if user_message is None: + return False + if isinstance(user_message, str): + if not user_message.strip(): + return False + content: Any = user_message + elif isinstance(user_message, list): + if not user_message: + return False + content = user_message + else: + return False + if ( + messages + and isinstance(messages[-1], dict) + and messages[-1].get("role") == "user" + and messages[-1].get("content") == content + ): + return False + messages.append({"role": "user", "content": content}) + return True + + +def _should_skip_model_call_for_reference_handoff( + messages: List[Dict[str, Any]], user_message: Any +) -> bool: + """Guard post-compaction continues against sole-handoff active turns (#80622).""" + from agent.context_compressor import reference_handoff_would_drive_next_model_call + + _restore_user_after_reference_handoff(messages, user_message) + return reference_handoff_would_drive_next_model_call(messages) + + +def _final_response_from_messages(messages: List[Dict[str, Any]]) -> str: + """Best-effort recovery of the last real assistant text after a skipped call.""" + from agent.context_compressor import is_compaction_summary_message + + for message in reversed(messages or []): + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + if message.get("tool_calls"): + continue + if is_compaction_summary_message(message): + continue + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content + return "" + + # Stable prefix of the local interrupt status string emitted when a turn is # cancelled while waiting on the provider. Surfaces (ACP, TUI) match on this # to treat it as cancellation metadata rather than assistant prose. @@ -2142,6 +2206,19 @@ def run_conversation( conversation_history = conversation_history_after_compression( agent, messages, conversation_history ) + if _should_skip_model_call_for_reference_handoff( + messages, user_message + ): + # Reference-only handoff must not become the active turn + # after a completed assistant response (#80622). + logger.info( + "Skipping post-compaction model call: reference-only " + "handoff would be the sole active user turn (#80622)" + ) + if not final_response: + final_response = _final_response_from_messages(messages) + _turn_exit_reason = "compaction_handoff_not_actionable" + break api_call_count -= 1 agent._api_call_count = api_call_count agent.iteration_budget.refund() @@ -5725,6 +5802,17 @@ def _perform_api_call(next_api_kwargs): messages, user_message ) agent._persist_user_message_idx = current_turn_user_idx + if _should_skip_model_call_for_reference_handoff( + messages, user_message + ): + logger.info( + "Skipping compressed-restart model call: reference-only " + "handoff would be the sole active user turn (#80622)" + ) + if not final_response: + final_response = _final_response_from_messages(messages) + _turn_exit_reason = "compaction_handoff_not_actionable" + break continue if _retry.restart_with_rebuilt_messages: @@ -6566,6 +6654,20 @@ def _perform_api_call(next_api_kwargs): conversation_history = conversation_history_after_compression( agent, messages, conversation_history ) + if _should_skip_model_call_for_reference_handoff( + messages, user_message + ): + logger.info( + "Skipping post-tool compaction model call: " + "reference-only handoff would be the sole " + "active user turn (#80622)" + ) + if not final_response: + final_response = _final_response_from_messages( + messages + ) + _turn_exit_reason = "compaction_handoff_not_actionable" + break elif agent.compression_enabled: # Over threshold but compression is blocked (summary-LLM # cooldown or anti-thrashing). Surface a deduped warning so diff --git a/agent/turn_context.py b/agent/turn_context.py index 92ebce833823..660659954ebd 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -179,20 +179,26 @@ def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> in meaningless. Prefer the LAST user message whose content exactly matches this turn's text — the surviving copy in the common case — so the injection stamp and the #48677 persist override can't land on a - todo-snapshot or historical row. Fall back to the last user message when - no exact match survives (merge-summary-into-tail rewrites the content but - the trackers still need a live anchor). Returns -1 when the list has no - user message at all. + todo-snapshot or historical row. Fall back to the last *user-originated* + turn when no exact match survives (merge-summary-into-tail rewrites the + content but the trackers still need a live anchor). Compaction handoffs + must never become the fallback anchor (#80622) — they are reference-only + scaffolding, not the active ask. Returns -1 when the list has no + user-originated message at all. """ + from agent.context_compressor import is_user_originated_turn + fallback = -1 for i in range(len(messages) - 1, -1, -1): msg = messages[i] if not (isinstance(msg, dict) and msg.get("role") == "user"): continue - if fallback < 0: - fallback = i if msg.get("content") == user_message: return i + # Prefer a real human turn over a synthetic handoff / continuation + # marker when the exact content was rewritten by merge-into-tail. + if fallback < 0 and is_user_originated_turn(msg): + fallback = i return fallback diff --git a/run_agent.py b/run_agent.py index 67cb22a71e29..84ee8849f7d2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2242,10 +2242,26 @@ def _flush_messages_to_session_db_unlocked( "codex_message_items": msg.get("codex_message_items"), "timestamp": _row_timestamp, "api_content": _row_api_content, + # Standalone reference handoffs are always hidden, even + # when the summarized transcript contained a user turn — + # otherwise they occupy the active user slot in + # retry/undo/session dispatch (#80622). Merge-into-tail + # carriers keep prior visibility rules so preserved tail + # content stays readable. "display_kind": ( "hidden" - if msg.get(COMPRESSED_SUMMARY_METADATA_KEY) - and not msg.get("_compressed_summary_has_user_turn") + if ( + msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and ( + ContextCompressor.classify_summary_content( + msg.get("content") + ) + == "standalone" + or not msg.get( + "_compressed_summary_has_user_turn" + ) + ) + ) else msg.get("display_kind") ), "display_metadata": msg.get("display_metadata"), diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 8a04660557a7..c2b4f793deae 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2395,15 +2395,18 @@ def _(rid, params: dict) -> dict: removed = 0 with session["history_lock"]: history = session.get("history", []) - # Truncate from the last *real* user turn (no display_kind). Popping - # only trailing assistant/tool then one user left timeline markers - # (async_delegation_complete, model_switch, …) as the undo target — - # so session.undo removed bookkeeping instead of the last exchange. + # Truncate from the last *real* user turn. Popping only trailing + # assistant/tool then one user left timeline markers + # (async_delegation_complete, model_switch, …) or compaction + # handoffs as the undo target — so session.undo removed + # bookkeeping instead of the last exchange (#80622). # Match list_recent_user_messages / CLI turn counting. + from agent.context_compressor import is_user_originated_turn + last_user_idx = None for i in range(len(history) - 1, -1, -1): msg = history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): last_user_idx = i break if last_user_idx is not None: diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 5896f1281c77..20c0721e05fa 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -710,15 +710,19 @@ def _(rid, params: dict) -> dict: if not history: return _err(rid, 4018, "no previous user message to retry") # Walk backwards to the last *real* user turn. Timeline bookkeeping - # rows (display_kind set) are durable role=user but no client counts - # them as user turns — same predicate as CLI resume/count and the - # prompt.submit ordinal fix. Without this, /retry re-sends opaque - # markers (model_switch / async_delegation_complete / auto_continue) - # and truncates only the marker instead of the failed exchange. + # rows (display_kind set) and compaction handoffs are durable + # role=user but must not count as user-originated asks — same + # predicate as CLI resume/count and the prompt.submit ordinal fix. + # Without this, /retry re-sends opaque markers (model_switch / + # async_delegation_complete / auto_continue / CONTEXT COMPACTION + # handoffs) and truncates only the marker instead of the failed + # exchange (#80622). + from agent.context_compressor import is_user_originated_turn + last_user_idx = None for i in range(len(history) - 1, -1, -1): msg = history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): last_user_idx = i break if last_user_idx is None: From dcb65a6b85d5b5881648f5283ded2c116cd39cff Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Fri, 7 Aug 2026 08:40:26 +0700 Subject: [PATCH 2/4] test(agent): cover reference-only handoff sole-active-turn regression Pin #80622 invariants: handoff alone must not drive a model call after stop, pending real users are restored, and synthetic compaction rows are never treated as user-originated turns. Also give micro-compaction enough passes to pay back the longer SUMMARY_PREFIX marker overhead. --- tests/agent/test_micro_compaction.py | 16 +- .../test_reference_handoff_active_turn.py | 184 ++++++++++++++++++ tests/agent/test_resume_stale_active_task.py | 2 + tests/agent/test_summary_prefix_semantics.py | 53 ++++- 4 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 tests/agent/test_reference_handoff_active_turn.py diff --git a/tests/agent/test_micro_compaction.py b/tests/agent/test_micro_compaction.py index 0292bf51a25f..4c56e585aad2 100644 --- a/tests/agent/test_micro_compaction.py +++ b/tests/agent/test_micro_compaction.py @@ -451,8 +451,9 @@ def explode(self): # pragma: no cover - must never be called def test_first_pass_costs_marker_overhead_then_pays_it_back(self): """The first pass can grow the transcript; later passes recover it. - Inserting the summary marker costs a fixed ~400 tokens of scaffolding - (the compaction preamble, the historical heading and the end marker). + Inserting the summary marker costs a fixed block of scaffolding + (``SUMMARY_PREFIX``, the historical heading and the end marker — + currently ~450 tokens and grows when the preamble is lengthened). On pass one that overhead is paid against a single absorbed exchange, so the net can be positive. From pass two on the marker is replaced rather than added, so the scaffolding is already paid for and each @@ -476,13 +477,20 @@ def test_first_pass_costs_marker_overhead_then_pays_it_back(self): assert after_many < after_first, "later passes must recover it" def test_cumulative_savings_accumulate_across_passes(self): + """Session-total savings go positive once marker overhead is paid back. + + The first pass inserts ``SUMMARY_PREFIX`` scaffolding (~450 tokens); + with the current preamble that alone leaves the cumulative counter + negative after only a few absorptions. Enough later passes must + still recover it — that is the amortization contract. + """ cc = _compressor() messages = _conversation(exchanges=10) - for _ in range(4): + for _ in range(6): messages = cc._micro_compact(messages) - assert cc._micro_compact_passes == 4 + assert cc._micro_compact_passes == 6 assert cc._micro_compact_tokens_saved_total > 0 def test_defrag_triggers_once_the_rolling_summary_grows(self): diff --git a/tests/agent/test_reference_handoff_active_turn.py b/tests/agent/test_reference_handoff_active_turn.py new file mode 100644 index 000000000000..4e62b0f9b83f --- /dev/null +++ b/tests/agent/test_reference_handoff_active_turn.py @@ -0,0 +1,184 @@ +"""Regression coverage for #80622: a reference-only compaction handoff must +never become the active user turn after a completed assistant response. + +Failure mode (real report): assistant finished with ``finish_reason=stop``, +Hermes inserted a standalone ``role=user`` ``[CONTEXT COMPACTION — REFERENCE +ONLY]`` handoff containing a Historical Task Snapshot, no new human request +followed, and the agent immediately started a tool-calling turn that resumed +the already-completed work. +""" + +from __future__ import annotations + +from agent.context_compressor import ( + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, + COMPRESSED_SUMMARY_METADATA_KEY, + COMPRESSION_CONTINUATION_USER_CONTENT, + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, + is_compaction_summary_message, + is_user_originated_turn, + reference_handoff_would_drive_next_model_call, +) +from agent.conversation_loop import ( + _should_skip_model_call_for_reference_handoff, +) +from agent.turn_context import reanchor_current_turn_user_idx + + +def _standalone_handoff(task: str = "finish the already-done refactor") -> dict: + return { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n" + f"User asked: '{task}'\n\n{_SUMMARY_END_MARKER}" + ), + COMPRESSED_SUMMARY_METADATA_KEY: True, + COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: True, + } + + +class TestReferenceHandoffWouldDriveNextModelCall: + def test_standalone_handoff_alone_drives(self): + messages = [_standalone_handoff()] + assert reference_handoff_would_drive_next_model_call(messages) is True + + def test_handoff_after_completed_stop_drives(self): + """The reported sequence: assistant stop, then synthetic handoff.""" + messages = [ + {"role": "user", "content": "please finish the refactor"}, + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + _standalone_handoff(), + ] + assert reference_handoff_would_drive_next_model_call(messages) is True + + def test_real_user_after_handoff_does_not_drive(self): + messages = [ + _standalone_handoff(), + {"role": "user", "content": "what's the capital of France?"}, + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + + def test_mid_tool_loop_after_handoff_does_not_drive(self): + messages = [ + _standalone_handoff(), + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}], + }, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + + def test_embedded_remainder_after_end_marker_does_not_drive(self): + messages = [ + { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold\n\n" + f"{_SUMMARY_END_MARKER}\n\nwhat's the capital of France?" + ), + COMPRESSED_SUMMARY_METADATA_KEY: True, + } + ] + assert reference_handoff_would_drive_next_model_call(messages) is False + + def test_continuation_marker_alone_still_drives(self): + """Synthetic continuation is not a human ask — sole-handoff + + continuation must not license a fresh tool loop after stop.""" + messages = [ + _standalone_handoff(), + {"role": "user", "content": COMPRESSION_CONTINUATION_USER_CONTENT}, + ] + assert reference_handoff_would_drive_next_model_call(messages) is True + + +class TestSkipGuardRestoresRealUser: + def test_restores_pending_user_then_allows_continue(self): + messages = [ + { + "role": "assistant", + "content": "done", + "finish_reason": "stop", + }, + _standalone_handoff(), + ] + assert _should_skip_model_call_for_reference_handoff( + messages, "new ask after compaction" + ) is False + assert messages[-1]["role"] == "user" + assert messages[-1]["content"] == "new ask after compaction" + + def test_skips_when_no_real_user_to_restore(self): + messages = [ + { + "role": "assistant", + "content": "Refactor complete.", + "finish_reason": "stop", + }, + _standalone_handoff(), + ] + assert _should_skip_model_call_for_reference_handoff(messages, None) is True + + +class TestUserOriginatedTurnPredicate: + def test_standalone_handoff_not_user_originated_even_with_has_user_turn(self): + handoff = _standalone_handoff() + assert is_compaction_summary_message(handoff) is True + assert is_user_originated_turn(handoff) is False + + def test_plain_user_is_originated(self): + assert is_user_originated_turn({"role": "user", "content": "hello"}) is True + + def test_display_kind_hidden_not_originated(self): + assert ( + is_user_originated_turn( + { + "role": "user", + "content": "opaque", + "display_kind": "hidden", + } + ) + is False + ) + + +class TestReanchorSkipsHandoffFallback: + def test_fallback_skips_standalone_handoff(self): + messages = [ + {"role": "system", "content": "sys"}, + _standalone_handoff(), + ] + assert reanchor_current_turn_user_idx(messages, "missing ask") == -1 + + def test_exact_match_still_wins(self): + messages = [ + _standalone_handoff(), + {"role": "user", "content": "live ask"}, + ] + assert reanchor_current_turn_user_idx(messages, "live ask") == 1 + + def test_fallback_prefers_real_user_over_handoff(self): + messages = [ + {"role": "user", "content": "original ask"}, + _standalone_handoff(), + ] + # Exact content rewritten by merge — fall back must not land on handoff. + assert reanchor_current_turn_user_idx(messages, "rewritten ask") == 0 + + +class TestNoToolCallsWithoutLaterRealUser: + def test_historical_snapshot_alone_is_not_actionable(self): + """Suggested regression #3: executable-looking snapshot must not + count as a user-originated turn.""" + handoff = _standalone_handoff( + "run npm test and fix every failure in the suite" + ) + assert is_user_originated_turn(handoff) is False + assert reference_handoff_would_drive_next_model_call([handoff]) is True diff --git a/tests/agent/test_resume_stale_active_task.py b/tests/agent/test_resume_stale_active_task.py index 5820e7b04941..9b224f43c3c8 100644 --- a/tests/agent/test_resume_stale_active_task.py +++ b/tests/agent/test_resume_stale_active_task.py @@ -60,6 +60,8 @@ def test_latest_message_wins_over_inherited_active_task(): # resumption on topic overlap (#41607, #38364) — it must stay gone. assert "you may use the summary as background" not in lower assert "topic overlap" in lower + # #80622: empty-after-handoff must not resume historical work. + assert "if no user message appears after this summary" in lower diff --git a/tests/agent/test_summary_prefix_semantics.py b/tests/agent/test_summary_prefix_semantics.py index 4e466308e5c2..4af2ec800840 100644 --- a/tests/agent/test_summary_prefix_semantics.py +++ b/tests/agent/test_summary_prefix_semantics.py @@ -75,6 +75,35 @@ def test_replaced_prefixes_are_frozen_for_renormalization(): # derive them from module constants — the tests below must fail if any # frozen entry is mutated, reordered, or dropped. _FROZEN_PREFIX_GENERATIONS = ( + # Pre-#80622: tools-active + topic-overlap discard, but no + # "if no user message appears AFTER this summary, do nothing" clause. + ( + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were " + "compacted into the summary below. This is a handoff from a " + "previous context window — treat it as background reference, NOT " + "as active instructions. Do NOT answer questions or fulfill " + "requests mentioned in this summary; they were already addressed. " + "Respond ONLY to the latest user message that appears AFTER this " + "summary — that message is the single source of truth for what to " + "do right now. Topic overlap with the summary does NOT mean you " + "should resume its task: even on similar topics, the latest user " + "message WINS. Treat ONLY the latest message as the active task " + "and discard stale items from '## Historical Task Snapshot' " + "entirely — do not 'wrap up' or 'finish' work described there " + "unless the latest message explicitly asks for it. Reverse " + "signals in the latest message (e.g. 'stop', 'undo', 'roll " + "back', 'just verify', 'don't do that anymore', 'never mind', a " + "new topic) must immediately end any in-flight work described in " + "the summary; do not re-surface it in later turns. IMPORTANT: " + "Your persistent memory (MEMORY.md, USER.md) in the system " + "prompt is ALWAYS authoritative and active — never ignore or " + "deprioritize memory content due to this compaction note. None " + "of the above restricts HOW you work: your tools remain fully " + "active — keep calling them normally for the active task (edit " + "files, run commands, search) instead of merely narrating what " + "you would do. The current session state (files, config, etc.) " + "may reflect work described here — avoid repeating it:" + ), # Pre-#69619: four-heading discard clause + tools-active clause. ( "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were " @@ -174,8 +203,18 @@ def test_replaced_prefixes_are_frozen_for_renormalization(): # The generation retired by #69619, pinned individually for the review -# regression below. -_PRE_69619_LIVE_PREFIX = _FROZEN_PREFIX_GENERATIONS[0] +# regression below. Index 1 after the #80622 freeze was prepended. +_PRE_69619_LIVE_PREFIX = _FROZEN_PREFIX_GENERATIONS[1] + + +def test_no_user_after_handoff_must_not_act(): + """#80622: a reference-only handoff with nothing after it must not + resume historical work or call tools.""" + lower = SUMMARY_PREFIX.lower() + assert "if no user message appears after this summary" in lower + assert "do nothing" in lower + assert "wait for a new user message" in lower + assert "must never become the active turn" in lower def test_pre_69619_prefix_generation_is_frozen_and_stripped(): @@ -199,3 +238,13 @@ def test_pre_69619_prefix_generation_is_frozen_and_stripped(): assert ContextCompressor._strip_summary_prefix(content) == "BODY" +def test_frozen_prefix_generations_match_historical_tuple(): + """Every retired generation must stay byte-identical in + _HISTORICAL_SUMMARY_PREFIXES (newest-first).""" + from agent.context_compressor import _HISTORICAL_SUMMARY_PREFIXES + + assert tuple(_HISTORICAL_SUMMARY_PREFIXES[: len(_FROZEN_PREFIX_GENERATIONS)]) == ( + _FROZEN_PREFIX_GENERATIONS + ) + + From 570049431028f78e22ef1e121af85457a8066135 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:59:56 +0530 Subject: [PATCH 3/4] =?UTF-8?q?fix(agent):=20finish=20the=20#80622=20bug?= =?UTF-8?q?=20class=20=E2=80=94=20sibling=20predicates,=20refund=20orderin?= =?UTF-8?q?g,=20prompt=20carve-out,=20honest=20skip=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on top of the salvaged #80696 fix (review findings): - Sibling sites: rollback.restore, gateway /retry, CLI /retry and /undo N, and both CLI resume turn counters now use is_user_originated_turn so legacy-persisted standalone handoffs (durable role=user, no display_kind) can never be truncation targets or counted as user turns (#80622 suggested regression 4, dispatcher-wide). - Site-1 guard: hoist the api_call_count decrement + iteration-budget refund above the break so a skipped turn no longer leaks a budget unit and finalize_turn logs the true call count (matches the ollama early-exit and the site-2 sibling). - Site-2 guard: run the handoff guard BEFORE reanchoring so a restored user ask is what the anchor lands on, not a stale pre-restore index. - SUMMARY_PREFIX: add the mid-tool-loop carve-out the code-side guard already implements, so a literal-minded model doesn't halt an in-flight exchange after in-place compaction. - Skip path returns a short compaction status instead of replaying the previous turn's answer (finalize_turn would append it as a fresh assistant row — duplicate prose in transcript and delivery). --- agent/context_compressor.py | 4 +- agent/conversation_loop.py | 61 +++++++++++++++++------------ cli.py | 17 +++++--- gateway/slash_commands.py | 8 +++- hermes_cli/cli_agent_setup_mixin.py | 6 ++- hermes_cli/cli_commands_mixin.py | 6 ++- tui_gateway/methods_tools.py | 11 ++++-- 7 files changed, 75 insertions(+), 38 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index ea39c0b0610c..8af36bfe2587 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -110,7 +110,9 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: "resume, wrap up, or continue work from " f"'{HISTORICAL_TASK_HEADING}' or any other section, do not call tools, " "and wait for a new user message. This handoff must never become the " - "active turn by itself. " + "active turn by itself. (Exception: if tool results or your own " + "tool calls appear after this summary, you are mid-way through an " + "in-flight exchange — continue that exchange normally.) " "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 67c15b85d3bf..39be581aae0f 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -142,20 +142,18 @@ def _should_skip_model_call_for_reference_handoff( def _final_response_from_messages(messages: List[Dict[str, Any]]) -> str: - """Best-effort recovery of the last real assistant text after a skipped call.""" - from agent.context_compressor import is_compaction_summary_message + """Fallback text for a turn ended by the sole-handoff skip (#80622). - for message in reversed(messages or []): - if not isinstance(message, dict) or message.get("role") != "assistant": - continue - if message.get("tool_calls"): - continue - if is_compaction_summary_message(message): - continue - content = message.get("content") - if isinstance(content, str) and content.strip(): - return content - return "" + Deliberately NOT a replay of the last assistant text: finalize_turn's + non-assistant-tail chokepoint (#43849) appends ``final_response`` as a + fresh assistant row, so recovering the previous turn's prose here would + duplicate it in the durable transcript AND re-deliver it to the user as + if it were this turn's answer. A short status is honest and idempotent. + """ + return ( + "Context was compacted. The previous response is complete — " + "awaiting your next message." + ) # Stable prefix of the local interrupt status string emitted when a turn is @@ -2206,6 +2204,17 @@ def run_conversation( conversation_history = conversation_history_after_compression( agent, messages, conversation_history ) + # This preflight iteration never reaches the provider whether + # we skip the turn (handoff guard below) or re-run the loop — + # refund the consumed call/budget in BOTH cases, mirroring the + # ollama_runtime_context_too_small early-exit above. Without + # the refund on the break path, every skipped turn leaked one + # iteration-budget unit for the agent's lifetime and + # finalize_turn logged an api_call_count including a call that + # was never made. + api_call_count -= 1 + agent._api_call_count = api_call_count + agent.iteration_budget.refund() if _should_skip_model_call_for_reference_handoff( messages, user_message ): @@ -2219,9 +2228,6 @@ def run_conversation( final_response = _final_response_from_messages(messages) _turn_exit_reason = "compaction_handoff_not_actionable" break - api_call_count -= 1 - agent._api_call_count = api_call_count - agent.iteration_budget.refund() continue elif ( agent.compression_enabled @@ -5792,16 +5798,6 @@ def _perform_api_call(next_api_kwargs): # to fit the context window. retry_count += 1 _retry.restart_with_compressed_messages = False - # In-loop compression rebuilt `messages` with fresh compaction - # copies, so the pre-compression current-turn index is stale. - # Re-anchor exactly like the prologue does: a stale index that - # lands on a historical user message would make the live-compose - # fallback inject this turn's prefetch into that message on the - # wire only, diverging the next turn's replayed prefix there. - current_turn_user_idx = reanchor_current_turn_user_idx( - messages, user_message - ) - agent._persist_user_message_idx = current_turn_user_idx if _should_skip_model_call_for_reference_handoff( messages, user_message ): @@ -5813,6 +5809,19 @@ def _perform_api_call(next_api_kwargs): final_response = _final_response_from_messages(messages) _turn_exit_reason = "compaction_handoff_not_actionable" break + # In-loop compression rebuilt `messages` with fresh compaction + # copies, so the pre-compression current-turn index is stale. + # Re-anchor exactly like the prologue does: a stale index that + # lands on a historical user message would make the live-compose + # fallback inject this turn's prefetch into that message on the + # wire only, diverging the next turn's replayed prefix there. + # Ordered AFTER the handoff guard: the guard may have re-appended + # this turn's real user ask (restore path), and the anchor must + # land on that restored row, not on -1 / a pre-restore index. + current_turn_user_idx = reanchor_current_turn_user_idx( + messages, user_message + ) + agent._persist_user_message_idx = current_turn_user_idx continue if _retry.restart_with_rebuilt_messages: diff --git a/cli.py b/cli.py index 541d112a5277..36030a676a19 100644 --- a/cli.py +++ b/cli.py @@ -8410,11 +8410,15 @@ def retry_last(self): # Walk backwards to the last *real* user message. Timeline bookkeeping # rows (display_kind set) are role=user but are not user turns — match - # CLI resume counting and list_recent_user_messages. + # CLI resume counting and list_recent_user_messages. Compaction + # handoffs are excluded too (durable role=user, sometimes without + # display_kind on legacy sessions; #80622). + from agent.context_compressor import is_user_originated_turn + last_user_idx = None for i in range(len(self.conversation_history) - 1, -1, -1): msg = self.conversation_history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): last_user_idx = i break @@ -8460,12 +8464,15 @@ def undo_last(self, n: int = 1, prefill: bool = True): n = 1 # Walk backwards collecting the indices of the last N *real* user - # messages (exclude display_kind timeline rows — same predicate as - # list_recent_user_messages and resume turn counting). + # messages (exclude display_kind timeline rows and compaction + # handoffs — same predicate as list_recent_user_messages, resume + # turn counting, and /retry; #80622). + from agent.context_compressor import is_user_originated_turn + user_indices = [] for i in range(len(self.conversation_history) - 1, -1, -1): msg = self.conversation_history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): user_indices.append(i) if len(user_indices) >= n: break diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 08202a8eef1a..8675f317f8b5 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2572,9 +2572,15 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: # and re-sent opaque bookkeeping text (same class as the TUI ordinal). last_user_msg = None last_user_idx = None + # is_user_originated_turn: excludes display_kind bookkeeping AND + # compaction handoffs (durable role=user, sometimes without + # display_kind on legacy sessions; #80622) — /retry must never + # re-send a reference-only summary as if the user asked it. + from agent.context_compressor import is_user_originated_turn + for i in range(len(history) - 1, -1, -1): msg = history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): last_user_msg = msg.get("content", "") last_user_idx = i break diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 57232dde27e8..40bbff5ede05 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -623,11 +623,15 @@ def _preload_resumed_session(self) -> bool: self._resume_display_history = [ m for m in display_history if m.get("role") != "session_meta" ] + from agent.context_compressor import is_user_originated_turn + + # Count only user-originated turns (#80622): legacy compaction + # handoffs are durable role=user rows without display_kind. msg_count = len( [ m for m in self._resume_display_history - if m.get("role") == "user" and not m.get("display_kind") + if is_user_originated_turn(m) ] ) title_part = "" diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 50bb4f7345b2..bb5e67874f71 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1110,7 +1110,11 @@ def _handle_resume_command(self, cmd_original: str) -> None: pass title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else "" - msg_count = len([m for m in self._resume_display_history if m.get("role") == "user" and not m.get("display_kind")]) + from agent.context_compressor import is_user_originated_turn + + # Count only user-originated turns (#80622): legacy compaction + # handoffs are durable role=user rows without display_kind. + msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)]) if self.conversation_history: _cprint( f" ↻ Resumed session {target_id}{title_part}" diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 20c0721e05fa..c1b56ee213a6 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1298,12 +1298,17 @@ def go(mgr, cwd): removed = 0 with session["history_lock"]: history = session.get("history", []) - # Truncate from the last *real* user turn (no display_kind). - # Same predicate as list_recent_user_messages / /undo / /retry. + # Truncate from the last *real* user turn. Same predicate + # as list_recent_user_messages / /undo / /retry — + # is_user_originated_turn also excludes compaction + # handoffs (durable role=user, sometimes without + # display_kind on legacy sessions; #80622). + from agent.context_compressor import is_user_originated_turn + last_user_idx = None for i in range(len(history) - 1, -1, -1): msg = history[i] - if msg.get("role") == "user" and not msg.get("display_kind"): + if is_user_originated_turn(msg): last_user_idx = i break if last_user_idx is not None: From d2dd32a66ee3a47c72deab4e386f117f01f3deea Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:21:14 +0530 Subject: [PATCH 4/4] =?UTF-8?q?refactor(agent):=20fold=20simplify=20findin?= =?UTF-8?q?gs=20=E2=80=94=20DB=20picker=20parity,=20single=20scan,=20canon?= =?UTF-8?q?ical=20strip=20delegation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-pass follow-ups (three parallel reviewers, findings verified): - hermes_state_search.py list_recent_user_messages now drops legacy standalone compaction handoffs in the decode loop (SQL can't see them: durable role=user, no display_kind). Closes the /undo N pairing skew where the in-memory count (new predicate) and the DB soft-delete pick (old predicate) targeted different turns on legacy sessions. Fetches with headroom so the requested limit is still honored. 3 new tests, mutation-checked (no-op'ing the skip fails 2/3). - _should_skip_model_call_for_reference_handoff: single drive-check scan (was two — once inside the restore helper, once after); the restore helper no longer re-scans and its return value now decides the verdict. - _final_response_from_messages replaced by the _HANDOFF_SKIP_FINAL_RESPONSE constant it always returned (parameter was unused). - _handoff_carries_live_user_content delegates to the canonical _strip_context_summary_handoff_message — also fixes the edge where a merged-shaped row with an EMPTY preserved prior tail was wrongly treated as carrying live content. - Site-level guard test for rollback.restore with a legacy handoff row (predicate-in-context, complements the unit tests). --- agent/context_compressor.py | 22 +++--- agent/conversation_loop.py | 50 ++++++------ hermes_state_search.py | 16 +++- ...test_list_recent_user_messages_handoffs.py | 79 +++++++++++++++++++ tests/test_tui_gateway_server.py | 60 ++++++++++++++ 5 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 tests/test_list_recent_user_messages_handoffs.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 8af36bfe2587..3c79550305cf 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -6966,18 +6966,22 @@ def _handoff_carries_live_user_content(message: Any) -> bool: Force-user-leading merges prepend the handoff + end marker to the real ask, leaving a non-empty remainder after ``_SUMMARY_END_MARKER``. Either shape must remain actionable (#80622 must not treat them as sole-handoff). + + Delegates to ``_strip_context_summary_handoff_message`` — the canonical + "does anything survive once the handoff is removed" logic (it also + handles multimodal list content and returns ``None`` for a merged-shaped + row whose preserved prior tail is EMPTY, which a bare + ``classify_summary_content(...) == "merged"`` check would wrongly treat + as live). Callers must pre-filter with ``is_compaction_summary_message``: + for non-summary rows the strip helper returns the message unchanged, + which would read as "carries live content" here. """ if not isinstance(message, dict): return False - content = message.get("content") - kind = ContextCompressor.classify_summary_content(content) - if kind == "merged": - return True - text = _content_text_for_contains(content) - marker_idx = text.find(_SUMMARY_END_MARKER) - if marker_idx < 0: - return False - return bool(text[marker_idx + len(_SUMMARY_END_MARKER) :].strip()) + return ( + ContextCompressor._strip_context_summary_handoff_message(message) + is not None + ) def reference_handoff_would_drive_next_model_call( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 39be581aae0f..a744f2a43fc0 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -100,14 +100,10 @@ def _restore_user_after_reference_handoff( ) -> bool: """Re-append this turn's real user ask when compaction left only a handoff. - Returns True when a restore append happened. Used before deciding whether - a post-compaction ``continue`` would let the reference-only summary drive - the next model call (#80622). + Returns True when a restore append happened. The caller has already + established that a reference-only handoff would drive the next model + call (#80622); this helper only decides whether a restorable ask exists. """ - from agent.context_compressor import reference_handoff_would_drive_next_model_call - - if not reference_handoff_would_drive_next_model_call(messages): - return False if user_message is None: return False if isinstance(user_message, str): @@ -137,23 +133,25 @@ def _should_skip_model_call_for_reference_handoff( """Guard post-compaction continues against sole-handoff active turns (#80622).""" from agent.context_compressor import reference_handoff_would_drive_next_model_call - _restore_user_after_reference_handoff(messages, user_message) - return reference_handoff_would_drive_next_model_call(messages) - + if not reference_handoff_would_drive_next_model_call(messages): + return False + if _restore_user_after_reference_handoff(messages, user_message): + # The restored ask is an actionable non-synthetic user row appended + # after the handoff — by construction the handoff no longer drives. + return False + return True -def _final_response_from_messages(messages: List[Dict[str, Any]]) -> str: - """Fallback text for a turn ended by the sole-handoff skip (#80622). - Deliberately NOT a replay of the last assistant text: finalize_turn's - non-assistant-tail chokepoint (#43849) appends ``final_response`` as a - fresh assistant row, so recovering the previous turn's prose here would - duplicate it in the durable transcript AND re-deliver it to the user as - if it were this turn's answer. A short status is honest and idempotent. - """ - return ( - "Context was compacted. The previous response is complete — " - "awaiting your next message." - ) +# Fallback final_response for a turn ended by the sole-handoff skip (#80622). +# Deliberately NOT a replay of the last assistant text: finalize_turn's +# non-assistant-tail chokepoint (#43849) appends final_response as a fresh +# assistant row, so recovering the previous turn's prose here would duplicate +# it in the durable transcript AND re-deliver it to the user as if it were +# this turn's answer. A short status is honest and idempotent. +_HANDOFF_SKIP_FINAL_RESPONSE = ( + "Context was compacted. The previous response is complete — " + "awaiting your next message." +) # Stable prefix of the local interrupt status string emitted when a turn is @@ -2225,7 +2223,7 @@ def run_conversation( "handoff would be the sole active user turn (#80622)" ) if not final_response: - final_response = _final_response_from_messages(messages) + final_response = _HANDOFF_SKIP_FINAL_RESPONSE _turn_exit_reason = "compaction_handoff_not_actionable" break continue @@ -5806,7 +5804,7 @@ def _perform_api_call(next_api_kwargs): "handoff would be the sole active user turn (#80622)" ) if not final_response: - final_response = _final_response_from_messages(messages) + final_response = _HANDOFF_SKIP_FINAL_RESPONSE _turn_exit_reason = "compaction_handoff_not_actionable" break # In-loop compression rebuilt `messages` with fresh compaction @@ -6672,9 +6670,7 @@ def _perform_api_call(next_api_kwargs): "active user turn (#80622)" ) if not final_response: - final_response = _final_response_from_messages( - messages - ) + final_response = _HANDOFF_SKIP_FINAL_RESPONSE _turn_exit_reason = "compaction_handoff_not_actionable" break elif agent.compression_enabled: diff --git a/hermes_state_search.py b/hermes_state_search.py index 32eae0ae77ae..e8f32aa5f41c 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -1103,19 +1103,33 @@ def list_recent_user_messages( active_clause = "" if include_inactive else " AND active = 1" # Match CLI/desktop: only real user turns, not timeline bookkeeping. display_clause = " AND (display_kind IS NULL OR display_kind = '')" + # Legacy standalone compaction handoffs (persisted pre-#80622) are + # durable role='user' rows with NO display_kind — SQL can't see them, + # so fetch with headroom and drop them in the decode loop below. + # Without this, /undo N and rewind pair an in-memory count that + # excludes handoffs with a DB pick that includes them, soft-deleting + # the wrong turn. + fetch_limit = int(limit) * 2 + 5 with self._lock: cursor = self._conn.execute( "SELECT id, timestamp, content FROM messages " "WHERE session_id = ? AND role = 'user'" f"{active_clause}{display_clause} " "ORDER BY id DESC LIMIT ?", - (session_id, int(limit)), + (session_id, fetch_limit), ) rows = cursor.fetchall() + from agent.context_compressor import ContextCompressor + result: List[Dict[str, Any]] = [] for row in rows: + if len(result) >= int(limit): + break decoded = self._decode_content(row["content"]) + if ContextCompressor._is_context_summary_content(decoded): + # Compaction handoff — never a user-originated turn (#80622). + continue if isinstance(decoded, list): # Multimodal — flatten text parts. text_parts = [ diff --git a/tests/test_list_recent_user_messages_handoffs.py b/tests/test_list_recent_user_messages_handoffs.py new file mode 100644 index 000000000000..6a6c9563bc78 --- /dev/null +++ b/tests/test_list_recent_user_messages_handoffs.py @@ -0,0 +1,79 @@ +"""list_recent_user_messages must skip legacy compaction handoffs (#80622). + +Legacy standalone ``[CONTEXT COMPACTION — REFERENCE ONLY]`` handoffs persisted +pre-#80622 are durable ``role='user'`` rows with NO ``display_kind``, so the +SQL-side display filter cannot exclude them. Every /undo-class command pairs an +in-memory count that (post-#80622) excludes handoffs via +``is_user_originated_turn`` with this DB picker — if the picker still counted +handoffs, the on-disk soft-delete would target a different turn than the +in-memory cut (memory/disk transcript divergence). + +Drives the real SQL + decode path through SessionDB. +""" + +import pytest + +from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, +) +from hermes_state import SessionDB + +HANDOFF_CONTENT = ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n" + f"User asked: 'old task'\n\n{_SUMMARY_END_MARKER}" +) + + +@pytest.fixture() +def db(tmp_path): + session_db = SessionDB(db_path=tmp_path / "state.db") + yield session_db + session_db.close() + + +def test_legacy_handoff_rows_are_not_recent_user_messages(db): + db.create_session(session_id="s1", source="cli", model="m") + db.append_message("s1", role="user", content="first question") + db.append_message("s1", role="assistant", content="first answer") + # Legacy shape: durable role=user handoff with NO display_kind. + db.append_message("s1", role="user", content=HANDOFF_CONTENT) + db.append_message("s1", role="user", content="second question") + db.append_message("s1", role="assistant", content="second answer") + + recents = db.list_recent_user_messages("s1", limit=10) + previews = [r["preview"] for r in recents] + + assert len(recents) == 2 + assert previews[0].startswith("second question") + assert previews[1].startswith("first question") + assert not any("[CONTEXT COMPACTION" in p for p in previews) + + +def test_handoff_skip_respects_limit_with_headroom(db): + """The requested limit is still honored when handoff rows are dropped.""" + db.create_session(session_id="s2", source="cli", model="m") + for i in range(3): + db.append_message("s2", role="user", content=HANDOFF_CONTENT) + db.append_message("s2", role="user", content=f"question {i}") + + recents = db.list_recent_user_messages("s2", limit=2) + + assert [r["preview"] for r in recents] == ["question 2", "question 1"] + + +def test_display_kind_rows_still_excluded(db): + """The pre-existing SQL-side display_kind filter is unchanged.""" + db.create_session(session_id="s3", source="cli", model="m") + db.append_message("s3", role="user", content="real question") + db.append_message( + "s3", + role="user", + content="background agent finished", + display_kind="async_delegation_complete", + ) + + recents = db.list_recent_user_messages("s3", limit=10) + + assert [r["preview"] for r in recents] == ["real question"] diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1b75d65f2fd1..c8e6679aa509 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -8653,6 +8653,66 @@ def restore(self, cwd, target, file_path=None): server._sessions.pop("sid", None) +def test_rollback_restore_skips_legacy_compaction_handoff(monkeypatch): + """rollback.restore must not truncate from a legacy standalone compaction + handoff — a durable role=user row persisted pre-#80622 with NO + display_kind. Same bug class as the display_kind marker above, caught + only by the is_user_originated_turn predicate. + """ + from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, + ) + + class _Mgr: + enabled = True + + def list_checkpoints(self, cwd): + return [{"hash": "abc123"}] + + def restore(self, cwd, target, file_path=None): + return {"success": True, "message": "restored"} + + handoff = { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n" + f"User asked: 'old task'\n\n{_SUMMARY_END_MARKER}" + ), + COMPRESSED_SUMMARY_METADATA_KEY: True, + # NOTE: no display_kind — the legacy-persistence shape (#80622). + } + history = [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "second answer"}, + handoff, + ] + server._sessions["sid"] = _session( + agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), + history=list(history), + ) + try: + resp = server.handle_request( + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "abc123"}, + } + ) + + assert resp["result"]["success"] is True + # Truncation lands on "second question", not the handoff row. + assert resp["result"]["history_removed"] == 3 # q2 + a2 + handoff + remaining = server._sessions["sid"]["history"] + assert [m["content"] for m in remaining] == ["first question", "first answer"] + finally: + server._sessions.pop("sid", None) + + # ── session.steer ────────────────────────────────────────────────────