diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7dd1e8967316..466c318c313c 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 @@ -6881,3 +6917,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 f2f48f53f48c..6c9117ca00f3 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. @@ -2068,6 +2132,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() @@ -5651,6 +5728,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: @@ -6492,6 +6580,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/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 + ) + + 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: