From 3f7d1c801ddfba87a4d0805d34159c830e7a2b7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 31 May 2026 21:04:49 -0700 Subject: [PATCH] feat(undo): /undo [N] backs up N user turns with prefill + soft-delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing /undo command from a single in-memory exchange removal into a full rewind: back up N user turns (default 1), soft-delete the truncated rows in SessionDB (active=0, kept for audit, hidden from re-prompts and search), notify memory providers, and prefill the composer with the backed-up message text for editing — CLI and TUI. Reuses the SessionDB rewind primitives, the on_session_switch(rewound=True) memory hook, and the TUI command.dispatch prefill payload from SaguaroDev's #21910 work, wired to /undo [N] instead of a separate /rewind picker. - cli.py: undo_last(n, prefill) — in-memory truncate + SQLite soft-delete + agent surgery (system-prompt invalidate, flush-index reset) + memory notify + editable buffer prefill; /undo dispatch parses optional count; checkpoint-rollback caller passes prefill=False - tui_gateway/server.py: command.dispatch undo branch (was rewind) parses count, picks Nth-from-last user turn, clamps to oldest - commands.py: /undo gains [N] args_hint - tests: rename + expand TUI suite (multi-turn, clamp, invalid-count) - release.py: AUTHOR_MAP entry for SaguaroDev Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com> --- cli.py | 183 +++++++++++++++--- hermes_cli/commands.py | 3 +- scripts/release.py | 1 + ...rewind_command.py => test_undo_command.py} | 79 +++++--- tui_gateway/server.py | 51 +++-- ui-tui/src/app/createSlashHandler.ts | 4 +- 6 files changed, 250 insertions(+), 71 deletions(-) rename tests/tui_gateway/{test_rewind_command.py => test_undo_command.py} (60%) diff --git a/cli.py b/cli.py index c14c71884..2f3d44a1f 100644 --- a/cli.py +++ b/cli.py @@ -5559,7 +5559,7 @@ class HermesCLI: # Also undo the last conversation turn so the agent's context # matches the restored filesystem state if self.conversation_history: - self.undo_last() + self.undo_last(prefill=False) print(" Chat turn undone to match restored file state.") else: print(f" ❌ {result['error']}") @@ -7103,37 +7103,156 @@ class HermesCLI: print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"") return last_message - def undo_last(self): - """Remove the last user/assistant exchange from conversation history. - - Walks backwards and removes all messages from the last user message - onward (including assistant responses, tool calls, etc.). + def undo_last(self, n: int = 1, prefill: bool = True): + """Back up N user turns: truncate history, soft-delete on disk, prefill. + + Walks backwards N user messages and discards everything from the + Nth-from-last user message onward (its assistant response, tool + calls, etc.). ``n`` defaults to 1 (the last exchange); ``/undo 3`` + backs up three user turns. If ``n`` exceeds the number of user + turns, it backs up to the oldest one. + + Beyond the in-memory ``conversation_history`` slice, this also: + • soft-deletes the truncated rows in SessionDB (``active=0``) so + they're hidden from re-prompts and search but kept for audit; + • notifies memory providers via ``on_session_switch(rewound=True)``; + • mirrors /branch's agent surgery (system-prompt invalidation + + flush-index reset); + • when ``prefill`` is set and an input buffer is available, + pre-fills the composer with the backed-up message text so it + can be edited and resubmitted. + + ``prefill=False`` is used by callers that drive the undo + programmatically (e.g. checkpoint rollback) and don't want to + touch the user's input buffer. """ if not self.conversation_history: print("(._.) No messages to undo.") return - - # Walk backwards to find the last user message - last_user_idx = None + + if n < 1: + n = 1 + + # Walk backwards collecting the indices of the last N user messages. + user_indices = [] for i in range(len(self.conversation_history) - 1, -1, -1): if self.conversation_history[i].get("role") == "user": - last_user_idx = i - break - - if last_user_idx is None: + user_indices.append(i) + if len(user_indices) >= n: + break + + if not user_indices: print("(._.) No user message found to undo.") return - - # Count how many messages we're removing - removed_count = len(self.conversation_history) - last_user_idx - removed_msg = self.conversation_history[last_user_idx].get("content", "") - - # Truncate history to before the last user message - self.conversation_history = self.conversation_history[:last_user_idx] - - print(f"(^_^)b Undid {removed_count} message(s). Removed: \"{removed_msg[:60]}{'...' if len(removed_msg) > 60 else ''}\"") + + # The oldest of the collected user messages is our truncation point. + cut_idx = user_indices[-1] + turns_undone = len(user_indices) + + removed_count = len(self.conversation_history) - cut_idx + removed_msg = self.conversation_history[cut_idx].get("content", "") + removed_text = self._undo_content_to_text(removed_msg) + + # Truncate the in-memory history to before that user message. + self.conversation_history = self.conversation_history[:cut_idx] + + # Soft-delete the truncated rows on disk so re-prompts and search + # see the clean transcript while the rows survive for audit. + rewound_rows = 0 + if self._session_db is not None and self.session_id: + try: + recents = self._session_db.list_recent_user_messages( + self.session_id, limit=max(turns_undone, 10) + ) + if recents: + target_idx = min(turns_undone - 1, len(recents) - 1) + target_id = recents[target_idx]["id"] + result = self._session_db.rewind_to_message( + self.session_id, target_id + ) + rewound_rows = result.get("rewound_count", 0) + # Prefer the DB's decoded target text for the prefill — + # it's the canonical persisted copy. + db_text = self._undo_content_to_text( + (result.get("target_message") or {}).get("content") + ) + if db_text: + removed_text = db_text + except ValueError as e: + # Non-user target / cross-session — keep the in-memory undo + # but skip the soft-delete; surface a debug-level note. + logger.debug("undo: soft-delete skipped: %s", e) + except Exception as e: + logger.debug("undo: soft-delete failed: %s", e) + + # Agent surgery: invalidate the system-prompt cache and reset the + # flush index so the next turn re-flushes from the truncated head. + if self.agent is not None: + if hasattr(self.agent, "_invalidate_system_prompt"): + try: + self.agent._invalidate_system_prompt() + except Exception: + pass + if hasattr(self.agent, "_last_flushed_db_idx"): + try: + self.agent._last_flushed_db_idx = len(self.conversation_history) + except Exception: + pass + # Notify memory providers — same hook /branch fires, with the + # rewound flag so per-turn document caches invalidate (#6672, #21910). + try: + _mm = getattr(self.agent, "_memory_manager", None) + if _mm is not None and self.session_id: + _mm.on_session_switch( + self.session_id, + parent_session_id="", + reset=False, + rewound=True, + ) + except Exception: + pass + + turn_word = "turn" if turns_undone == 1 else "turns" + msg_count = rewound_rows or removed_count + print( + f"(^_^)b Undid {turns_undone} {turn_word} ({msg_count} message(s)). " + f"Backed up to: \"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}\"" + ) remaining = len(self.conversation_history) print(f" {remaining} message(s) remaining in history.") + + # Pre-fill the composer with the backed-up message so the user can + # edit and resubmit (Claude-Code-style). Editable, not auto-sent. + if prefill and removed_text: + self._prefill_input_buffer(removed_text) + + @staticmethod + def _undo_content_to_text(content) -> str: + """Flatten message content (str or content-part list) to plain text.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + return "\n".join(t for t in parts if t) + return "" + + def _prefill_input_buffer(self, text: str) -> None: + """Place ``text`` in the active prompt_toolkit buffer, editable.""" + app = getattr(self, "_app", None) + if app is None: + return + try: + buf = app.current_buffer + buf.text = text + if hasattr(buf, "cursor_position"): + buf.cursor_position = len(text) + app.invalidate() + except Exception as e: + logger.debug("undo: prefill buffer failed: %s", e) def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None: """Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly.""" @@ -8599,13 +8718,29 @@ class HermesCLI: # Re-queue the message so process_loop sends it to the agent self._pending_input.put(retry_msg) elif canonical == "undo": + # Parse optional turn count: "/undo" → 1, "/undo 3" → 3. + _undo_n = 1 + _undo_parts = cmd_original.split() + if len(_undo_parts) > 1: + try: + _undo_n = int(_undo_parts[1]) + except ValueError: + print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.") + return + if _undo_n < 1: + _undo_n = 1 + _undo_desc = ( + "This removes the last user/assistant exchange from history." + if _undo_n == 1 + else f"This removes the last {_undo_n} user turns from history." + ) if self._confirm_destructive_slash( "undo", - "This removes the last user/assistant exchange from history.", + _undo_desc, cmd_original=cmd_original, ) is None: return - self.undo_last() + self.undo_last(_undo_n) elif canonical == "branch": self._handle_branch_command(cmd_original) elif canonical == "save": diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a2db37be2..4979a32cb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -78,7 +78,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("save", "Save the current conversation", "Session", cli_only=True), CommandDef("retry", "Retry the last message (resend to agent)", "Session"), - CommandDef("undo", "Remove the last user/assistant exchange", "Session"), + CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session", + args_hint="[N]"), CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"), CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session", diff --git a/scripts/release.py b/scripts/release.py index 8b0b9fc46..a3491d138 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev", "zhipengli@thebrainly.ai": "a1245582339", "mathijs.vd.hurk@gmail.com": "mathijsvandenhurk", "drpelagik@gmail.com": "SeaXen", diff --git a/tests/tui_gateway/test_rewind_command.py b/tests/tui_gateway/test_undo_command.py similarity index 60% rename from tests/tui_gateway/test_rewind_command.py rename to tests/tui_gateway/test_undo_command.py index ae2de14e2..fd1dbca59 100644 --- a/tests/tui_gateway/test_rewind_command.py +++ b/tests/tui_gateway/test_undo_command.py @@ -1,13 +1,14 @@ -"""Tests for /rewind handling in tui_gateway. +"""Tests for /undo handling in tui_gateway. -The TUI routes ``/rewind`` through ``command.dispatch`` (it's in +The TUI routes ``/undo`` through ``command.dispatch`` (it's in ``_PENDING_INPUT_COMMANDS`` because the CLI handler queues input the slash-worker subprocess can't read). The server handles it directly, mutates SessionDB to soft-delete rows, refreshes the in-memory session history, fires the memory-provider hook with ``rewound=True``, and returns ``{"type": "prefill", "message": , "notice": ...}`` so the Ink client drops the message into the composer for editing. -See issue #21910. + +``/undo N`` backs up N user turns at once (default 1). See issue #21910. """ from __future__ import annotations @@ -57,8 +58,8 @@ def db(hermes_home): @pytest.fixture() def session_with_history(server, db): """Build a session with 3 user turns + assistant replies persisted in DB.""" - sid = "sid-rewind" - session_key = "tui-rewind-1" + sid = "sid-undo" + session_key = "tui-undo-1" db.create_session(session_key, source="tui") for i in range(1, 4): db.append_message(session_key, "user", f"question {i}") @@ -87,20 +88,20 @@ def _call(server, method, **params): return server._methods[method](1, params) -def test_rewind_returns_prefill_with_target_text(server, session_with_history): +def test_undo_returns_prefill_with_target_text(server, session_with_history): sid, session_key, s, agent = session_with_history - resp = _call(server, "command.dispatch", session_id=sid, name="rewind", arg="") + resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="") result = resp["result"] assert result["type"] == "prefill" - # v1 auto-picks the most recent user turn — "question 3" + # Default /undo backs up one user turn — "question 3" assert result["message"] == "question 3" - assert "Rewound" in result["notice"] + assert "Undid" in result["notice"] -def test_rewind_truncates_in_memory_history(server, session_with_history, db): +def test_undo_truncates_in_memory_history(server, session_with_history, db): sid, session_key, s, agent = session_with_history - _call(server, "command.dispatch", session_id=sid, name="rewind", arg="") - # After rewinding to "question 3", active history should be 4 rows: + _call(server, "command.dispatch", session_id=sid, name="undo", arg="") + # After undoing to "question 3", active history should be 4 rows: # user q1, asst a1, user q2, asst a2 assert len(s["history"]) == 4 roles = [m["role"] for m in s["history"]] @@ -109,14 +110,42 @@ def test_rewind_truncates_in_memory_history(server, session_with_history, db): assert s["history_version"] == 1 -def test_rewind_soft_deletes_rows_in_db(server, session_with_history, db): +def test_undo_n_backs_up_multiple_turns(server, session_with_history, db): + """/undo 2 backs up two user turns to "question 2".""" + sid, session_key, s, agent = session_with_history + resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="2") + result = resp["result"] + assert result["type"] == "prefill" + assert result["message"] == "question 2" + assert "2 turns" in result["notice"] + # Active history truncated to user q1 + asst a1 + assert len(s["history"]) == 2 + assert [m["role"] for m in s["history"]] == ["user", "assistant"] + + +def test_undo_n_clamps_to_oldest_turn(server, session_with_history, db): + """/undo with N larger than the number of user turns backs up to the oldest.""" + sid, session_key, s, agent = session_with_history + resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="99") + result = resp["result"] + assert result["message"] == "question 1" + assert len(s["history"]) == 0 + + +def test_undo_rejects_invalid_count(server, session_with_history): + sid, _, _, _ = session_with_history + resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="abc") + assert "error" in resp + assert "invalid count" in resp["error"]["message"].lower() + + +def test_undo_soft_deletes_rows_in_db(server, session_with_history, db): sid, session_key, _, _ = session_with_history - _call(server, "command.dispatch", session_id=sid, name="rewind", arg="") + _call(server, "command.dispatch", session_id=sid, name="undo", arg="") # All rows still present all_rows = db.get_messages(session_key, include_inactive=True) assert len(all_rows) == 6 - # 2 inactive (the "question 3" row + its trailing siblings — here just - # "question 3" + "answer 3", since target was the q3 user row). + # 2 inactive (the "question 3" row + its trailing "answer 3"). active = [r for r in all_rows if r["active"] == 1] assert len(active) == 4 # rewind_count bumped @@ -124,9 +153,9 @@ def test_rewind_soft_deletes_rows_in_db(server, session_with_history, db): assert sess["rewind_count"] == 1 -def test_rewind_notifies_memory_provider(server, session_with_history): +def test_undo_notifies_memory_provider(server, session_with_history): sid, session_key, _, agent = session_with_history - _call(server, "command.dispatch", session_id=sid, name="rewind", arg="") + _call(server, "command.dispatch", session_id=sid, name="undo", arg="") agent._memory_manager.on_session_switch.assert_called_once() args, kwargs = agent._memory_manager.on_session_switch.call_args assert args[0] == session_key @@ -134,21 +163,21 @@ def test_rewind_notifies_memory_provider(server, session_with_history): assert kwargs["reset"] is False -def test_rewind_refuses_when_session_busy(server, session_with_history): +def test_undo_refuses_when_session_busy(server, session_with_history): sid, _, s, _ = session_with_history s["running"] = True - resp = _call(server, "command.dispatch", session_id=sid, name="rewind", arg="") + resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="") assert "error" in resp assert "busy" in resp["error"]["message"].lower() -def test_rewind_errors_when_no_active_session(server): - resp = _call(server, "command.dispatch", session_id="no-such-sid", name="rewind", arg="") +def test_undo_errors_when_no_active_session(server): + resp = _call(server, "command.dispatch", session_id="no-such-sid", name="undo", arg="") assert "error" in resp assert "no active session" in resp["error"]["message"].lower() -def test_rewind_in_pending_input_commands(server): - """Registry sanity: /rewind must be in _PENDING_INPUT_COMMANDS so +def test_undo_in_pending_input_commands(server): + """Registry sanity: /undo must be in _PENDING_INPUT_COMMANDS so slash.exec rejects it and the TUI falls through to command.dispatch.""" - assert "rewind" in server._PENDING_INPUT_COMMANDS + assert "undo" in server._PENDING_INPUT_COMMANDS diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 26be2db38..132b16d10 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5496,7 +5496,7 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( "steer", "plan", "goal", - "rewind", + "undo", } ) @@ -5882,39 +5882,50 @@ def _(rid, params: dict) -> dict: {"type": "send", "notice": notice, "message": state.goal}, ) - if name == "rewind": - # /rewind: pick the most-recent user message and prefill the - # composer with its text after soft-deleting everything that - # came after it in the transcript. v1 auto-picks the latest - # user turn (Claude-Code-style single-step undo); a multi-step - # picker UI is tracked as a follow-up to issue #21910. + if name == "undo": + # /undo [N]: back up N user turns (default 1), soft-delete the + # truncated rows on disk, and prefill the composer with the text + # of the user message we backed up to so it can be edited and + # resubmitted. N=1 is the Claude-Code-style single-step undo; + # /undo 3 backs up three user turns at once. See issue #21910. if not session: - return _err(rid, 4001, "no active session to rewind") + return _err(rid, 4001, "no active session to undo") if session.get("running"): return _err( - rid, 4009, "session busy — /interrupt the current turn before /rewind" + rid, 4009, "session busy — /interrupt the current turn before /undo" ) db = _get_db() if db is None: return _db_unavailable_error(rid, code=5008) session_key = session.get("session_key", "") if not session_key: - return _err(rid, 4001, "no session key for rewind") + return _err(rid, 4001, "no session key for undo") + # Parse the optional count argument (e.g. "/undo 3" → 3). + n = 1 + arg_str = (arg or "").strip() + if arg_str: + try: + n = int(arg_str.split()[0]) + except (ValueError, IndexError): + return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") + if n < 1: + n = 1 try: - recents = db.list_recent_user_messages(session_key, limit=10) + recents = db.list_recent_user_messages(session_key, limit=max(n, 10)) except Exception as e: - return _err(rid, 5008, f"rewind: failed to load history: {e}") + return _err(rid, 5008, f"undo: failed to load history: {e}") if not recents: - return _err(rid, 4018, "no user messages to rewind to") - # v1: auto-pick the most recent user turn. The Ink UI does not - # yet host a dedicated picker overlay (#21910 follow-up). - target_id = recents[0]["id"] + return _err(rid, 4018, "no user messages to undo") + # recents[0] is the most-recent user turn; pick the Nth-from-last. + # If N exceeds the number of user turns, back up to the oldest. + target_idx = min(n - 1, len(recents) - 1) + target_id = recents[target_idx]["id"] try: result = db.rewind_to_message(session_key, target_id) except ValueError as e: - return _err(rid, 4004, f"rewind: {e}") + return _err(rid, 4004, f"undo: {e}") except Exception as e: - return _err(rid, 5008, f"rewind: {e}") + return _err(rid, 5008, f"undo: {e}") # Reload the active-only transcript into the in-memory session # history so subsequent turns see the truncated view. try: @@ -5961,8 +5972,10 @@ def _(rid, params: dict) -> dict: if not isinstance(target_text, str): target_text = "" rewound_count = result.get("rewound_count", 0) + turns_undone = target_idx + 1 + turn_word = "turn" if turns_undone == 1 else "turns" notice = ( - f"↶ Rewound {rewound_count} message(s). " + f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). " "Edit and resubmit, or send a new message." ) return _ok( diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 71e2536d8..9148b5beb 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -121,8 +121,8 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } if (d.type === 'prefill') { - // /rewind returns prefill: drop the chosen text into the - // composer so the user can edit and resubmit, instead of + // /undo returns prefill: drop the backed-up message text into + // the composer so the user can edit and resubmit, instead of // submitting it immediately like 'send'. if (d.notice?.trim()) { sys(d.notice)