feat(gateway): bring /undo [N] to messaging platforms (parity with CLI/TUI) (#36699)
Gateway /undo was wired into every platform but still ran the old
single-turn hard-truncate. Now it matches the CLI/TUI: /undo [N] backs
up N user turns (default 1, clamps to oldest), soft-deletes the
truncated rows on disk (active=0, kept for audit, hidden from re-prompts
and search) via SessionDB.rewind_to_message, evicts the cached agent so
the next turn rebuilds from the active-only transcript (the gateway's
equivalent of the CLI's in-place history surgery + memory invalidation),
and echoes the backed-up message text so the user can copy/edit and
resend — platforms have no editable composer to prefill.
- gateway/session.py: SessionStore.rewind_session(session_id, n) wraps
the soft-delete primitive; load_transcript already returns active-only
- gateway/run.py: _handle_undo_command parses [N], calls rewind_session,
evicts the agent, echoes target text; confirm-prompt detail is count-aware
- locales: undo.removed gains {turns}; new undo.invalid_count, all 16 langs
- tests: tests/gateway/test_undo_rewind_session.py (6 cases)
This commit is contained in:
@ -8022,11 +8022,23 @@ class GatewayRunner:
|
||||
if canonical == "undo":
|
||||
async def _do_undo():
|
||||
return await self._handle_undo_command(event)
|
||||
_undo_n = 1
|
||||
_undo_raw = event.get_command_args().strip()
|
||||
if _undo_raw:
|
||||
try:
|
||||
_undo_n = max(1, int(_undo_raw.split()[0]))
|
||||
except (ValueError, IndexError):
|
||||
_undo_n = 1
|
||||
_undo_detail = (
|
||||
"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."
|
||||
)
|
||||
return await self._maybe_confirm_destructive_slash(
|
||||
event=event,
|
||||
command="undo",
|
||||
title="/undo",
|
||||
detail="This removes the last user/assistant exchange from history.",
|
||||
detail=_undo_detail,
|
||||
execute=_do_undo,
|
||||
)
|
||||
|
||||
@ -11618,29 +11630,53 @@ class GatewayRunner:
|
||||
logger.debug("goal continuation: enqueue failed: %s", exc)
|
||||
|
||||
async def _handle_undo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /undo command - remove the last user/assistant exchange."""
|
||||
"""Handle /undo [N] — back up N user turns (default 1), soft-deleting
|
||||
the truncated rows on disk and echoing the backed-up message text so
|
||||
the user can copy/edit and resend.
|
||||
|
||||
Mirrors the CLI/TUI /undo: rewound rows stay in state.db (active=0)
|
||||
for audit and are hidden from re-prompts and search. The cached agent
|
||||
is evicted so the next message rebuilds context from the truncated
|
||||
(active-only) transcript — the gateway's equivalent of the CLI's
|
||||
in-place history surgery + memory-cache invalidation.
|
||||
"""
|
||||
source = event.source
|
||||
|
||||
# Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
|
||||
n = 1
|
||||
raw_args = event.get_command_args().strip()
|
||||
if raw_args:
|
||||
try:
|
||||
n = int(raw_args.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return t("gateway.undo.invalid_count", arg=raw_args.split()[0])
|
||||
if n < 1:
|
||||
n = 1
|
||||
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
history = self.session_store.load_transcript(session_entry.session_id)
|
||||
|
||||
# Find the last user message and remove everything from it onward
|
||||
last_user_idx = None
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
|
||||
if last_user_idx is None:
|
||||
result = self.session_store.rewind_session(session_entry.session_id, n)
|
||||
|
||||
if result is None:
|
||||
return t("gateway.undo.nothing")
|
||||
|
||||
removed_msg = history[last_user_idx].get("content", "")
|
||||
removed_count = len(history) - last_user_idx
|
||||
self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx])
|
||||
# Reset stored token count — transcript was truncated
|
||||
|
||||
# Reset stored token count — transcript was truncated.
|
||||
session_entry.last_prompt_tokens = 0
|
||||
|
||||
preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg
|
||||
return t("gateway.undo.removed", count=removed_count, preview=preview)
|
||||
# Evict the cached agent so the next turn rebuilds from the active-only
|
||||
# transcript and memory providers refresh their per-session caches.
|
||||
try:
|
||||
session_key = build_session_key(source)
|
||||
self._evict_cached_agent(session_key)
|
||||
except Exception as e:
|
||||
logger.debug("undo: cached-agent eviction skipped: %s", e)
|
||||
|
||||
target_text = result["target_text"]
|
||||
preview = target_text[:200] + "..." if len(target_text) > 200 else target_text
|
||||
return t(
|
||||
"gateway.undo.removed",
|
||||
turns=result["turns_undone"],
|
||||
count=result["rewound_count"],
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
async def _handle_set_home_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /sethome command -- set the current chat as the platform's home channel."""
|
||||
|
||||
@ -1309,6 +1309,58 @@ class SessionStore:
|
||||
logger.debug("Could not load messages from DB: %s", e)
|
||||
return []
|
||||
|
||||
def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any]]:
|
||||
"""Back up ``n`` user turns via soft-delete, keeping rows for audit.
|
||||
|
||||
Unlike :meth:`rewrite_transcript` (a hard replace used by /retry),
|
||||
this flips the truncated rows to ``active=0`` in state.db so they
|
||||
survive for audit and stay hidden from re-prompts and search. Mirrors
|
||||
the CLI/TUI ``/undo [N]`` behavior via ``SessionDB.rewind_to_message``.
|
||||
|
||||
Returns a dict ``{"rewound_count", "turns_undone", "target_text"}`` on
|
||||
success, or ``None`` if there's no DB or no user message to back up to.
|
||||
``n`` clamps to the oldest user turn when it exceeds the turn count.
|
||||
"""
|
||||
if not self._db:
|
||||
return None
|
||||
if n < 1:
|
||||
n = 1
|
||||
try:
|
||||
recents = self._db.list_recent_user_messages(session_id, limit=max(n, 10))
|
||||
except Exception as e:
|
||||
logger.debug("rewind_session: failed to list user messages: %s", e)
|
||||
return None
|
||||
if not recents:
|
||||
return None
|
||||
target_idx = min(n - 1, len(recents) - 1)
|
||||
target_id = recents[target_idx]["id"]
|
||||
try:
|
||||
result = self._db.rewind_to_message(session_id, target_id)
|
||||
except ValueError as e:
|
||||
logger.debug("rewind_session: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("rewind_session: rewind_to_message failed: %s", e)
|
||||
return None
|
||||
target_msg = result.get("target_message") or {}
|
||||
content = target_msg.get("content") or ""
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
target_text = "\n".join(t for t in parts if t)
|
||||
elif isinstance(content, str):
|
||||
target_text = content
|
||||
else:
|
||||
target_text = ""
|
||||
return {
|
||||
"rewound_count": result.get("rewound_count", 0),
|
||||
"turns_undone": target_idx + 1,
|
||||
"target_text": target_text,
|
||||
}
|
||||
|
||||
|
||||
def build_session_context(
|
||||
source: SessionSource,
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Niks om ongedaan te maak nie."
|
||||
removed: "↩️ {count} boodskap(pe) ongedaan gemaak.\nVerwyder: \"{preview}\""
|
||||
removed: "↩️ {turns} beurt(e) ongedaan gemaak ({count} boodskap(pe)).\nGerugsteun na: \"{preview}\"\nKopieer/wysig die teks hierbo en stuur dit om van hier af weer te vra."
|
||||
invalid_count: "Ongeldige getal \"{arg}\" — gebruik /undo of /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update is slegs beskikbaar vanaf boodskapplatforms. Voer `hermes update` vanaf die terminale uit."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Nichts zum Rückgängigmachen."
|
||||
removed: "↩️ {count} Nachricht(en) rückgängig gemacht.\nEntfernt: \"{preview}\""
|
||||
removed: "↩️ {turns} Zug/Züge rückgängig gemacht ({count} Nachricht(en)).\nGesichert nach: \"{preview}\"\nKopieren/bearbeiten Sie den obigen Text und senden Sie ihn, um von hier aus erneut zu fragen."
|
||||
invalid_count: "Ungültige Anzahl \"{arg}\" — verwenden Sie /undo oder /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update ist nur auf Messaging-Plattformen verfügbar. Führen Sie `hermes update` im Terminal aus."
|
||||
|
||||
@ -305,7 +305,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Nothing to undo."
|
||||
removed: "↩️ Undid {count} message(s).\nRemoved: \"{preview}\""
|
||||
removed: "↩️ Undid {turns} turn(s) ({count} message(s)).\nBacked up to: \"{preview}\"\nCopy/edit the text above and send it to re-prompt from here."
|
||||
invalid_count: "Invalid count \"{arg}\" — use /undo or /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Nada que deshacer."
|
||||
removed: "↩️ {count} mensaje(s) deshecho(s).\nEliminado: \"{preview}\""
|
||||
removed: "↩️ {turns} turno(s) deshecho(s) ({count} mensaje(s)).\nRespaldado en: \"{preview}\"\nCopia/edita el texto de arriba y envíalo para volver a preguntar desde aquí."
|
||||
invalid_count: "Cantidad no válida \"{arg}\" — usa /undo o /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update solo está disponible en plataformas de mensajería. Ejecuta `hermes update` desde la terminal."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Rien à annuler."
|
||||
removed: "↩️ {count} message(s) annulé(s).\nSupprimé : « {preview} »"
|
||||
removed: "↩️ {turns} tour(s) annulé(s) ({count} message(s)).\nSauvegardé dans : « {preview} »\nCopiez/modifiez le texte ci-dessus et envoyez-le pour relancer à partir d'ici."
|
||||
invalid_count: "Nombre invalide « {arg} » — utilisez /undo ou /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update n'est disponible que depuis les plateformes de messagerie. Exécutez `hermes update` depuis le terminal."
|
||||
|
||||
@ -294,7 +294,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Níl aon rud le cealú."
|
||||
removed: "↩️ Cealaíodh {count} teachtaireacht.\nBaineadh: \"{preview}\""
|
||||
removed: "↩️ Cealaíodh {turns} seal ({count} teachtaireacht).\nCúltacaíodh chuig: \"{preview}\"\nCóipeáil/cuir an téacs thuas in eagar agus seol é chun athleideadh ón áit seo."
|
||||
invalid_count: "Líon neamhbhailí \"{arg}\" — úsáid /undo nó /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ Tá /update ar fáil amháin ó ardáin teachtaireachtaí. Rith `hermes update` ón teirminéal."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Nincs mit visszavonni."
|
||||
removed: "↩️ {count} üzenet visszavonva.\nEltávolítva: \"{preview}\""
|
||||
removed: "↩️ {turns} fordulat visszavonva ({count} üzenet).\nBiztonsági mentés ide: \"{preview}\"\nMásold/szerkeszd a fenti szöveget, és küldd el, hogy innen újra kérdezz."
|
||||
invalid_count: "Érvénytelen szám \"{arg}\" — használd a /undo vagy /undo N parancsot."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ A /update csak üzenetküldő platformokról érhető el. Futtasd a `hermes update` parancsot a terminálból."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Niente da annullare."
|
||||
removed: "↩️ Annullati {count} messaggio/i.\nRimosso: \"{preview}\""
|
||||
removed: "↩️ Annullati {turns} turno/i ({count} messaggio/i).\nSalvato in: \"{preview}\"\nCopia/modifica il testo qui sopra e invialo per ripartire da qui."
|
||||
invalid_count: "Conteggio non valido \"{arg}\" — usa /undo o /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update è disponibile solo dalle piattaforme di messaggistica. Esegui `hermes update` dal terminale."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "元に戻せる操作がありません。"
|
||||
removed: "↩️ {count} 件のメッセージを取り消しました。\n削除: 「{preview}」"
|
||||
removed: "↩️ {turns} ターンを取り消しました({count} 件のメッセージ)。\nバックアップ先: 「{preview}」\n上のテキストをコピー/編集して送信すると、ここから再入力できます。"
|
||||
invalid_count: "無効な数値「{arg}」— /undo または /undo N を使用してください。"
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update はメッセージングプラットフォームでのみ利用可能です。ターミナルで `hermes update` を実行してください。"
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "되돌릴 내용이 없습니다."
|
||||
removed: "↩️ 메시지 {count}개를 되돌렸습니다.\n제거됨: \"{preview}\""
|
||||
removed: "↩️ {turns}개 턴을 되돌렸습니다 (메시지 {count}개).\n백업 위치: \"{preview}\"\n위 텍스트를 복사/편집한 후 보내면 여기서 다시 프롬프트할 수 있습니다."
|
||||
invalid_count: "잘못된 개수 \"{arg}\" — /undo 또는 /undo N을 사용하세요."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update는 메시징 플랫폼에서만 사용할 수 있습니다. 터미널에서 `hermes update`를 실행하세요."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Nada para anular."
|
||||
removed: "↩️ {count} mensagem(ns) anulada(s).\nRemovido: \"{preview}\""
|
||||
removed: "↩️ {turns} turno(s) anulado(s) ({count} mensagem(ns)).\nGuardado em: \"{preview}\"\nCopia/edita o texto acima e envia-o para voltar a perguntar a partir daqui."
|
||||
invalid_count: "Contagem inválida \"{arg}\" — usa /undo ou /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update só está disponível em plataformas de mensagens. Executa `hermes update` a partir do terminal."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Нечего отменять."
|
||||
removed: "↩️ Отменено сообщений: {count}.\nУдалено: «{preview}»"
|
||||
removed: "↩️ Отменено ходов: {turns} (сообщений: {count}).\nРезервная копия: «{preview}»\nСкопируйте/отредактируйте текст выше и отправьте его, чтобы запросить заново отсюда."
|
||||
invalid_count: "Недопустимое число «{arg}» — используйте /undo или /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update доступен только на платформах обмена сообщениями. Выполните `hermes update` в терминале."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Geri alınacak bir şey yok."
|
||||
removed: "↩️ {count} mesaj geri alındı.\nKaldırıldı: \"{preview}\""
|
||||
removed: "↩️ {turns} tur geri alındı ({count} mesaj).\nYedeklendiği yer: \"{preview}\"\nYukarıdaki metni kopyalayıp/düzenleyip göndererek buradan yeniden sorabilirsiniz."
|
||||
invalid_count: "Geçersiz sayı \"{arg}\" — /undo veya /undo N kullanın."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update yalnızca mesajlaşma platformlarında kullanılabilir. Terminalden `hermes update` komutunu çalıştırın."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "Немає чого скасовувати."
|
||||
removed: "↩️ Скасовано {count} повідомлень.\nВидалено: «{preview}»"
|
||||
removed: "↩️ Скасовано ходів: {turns} (повідомлень: {count}).\nРезервна копія: «{preview}»\nСкопіюйте/відредагуйте текст вище та надішліть його, щоб запитати знову звідси."
|
||||
invalid_count: "Недійсне число «{arg}» — використовуйте /undo або /undo N."
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update доступний лише на платформах обміну повідомленнями. Виконайте `hermes update` у терміналі."
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "沒有可復原的內容。"
|
||||
removed: "↩️ 已復原 {count} 則訊息。\n已移除:「{preview}」"
|
||||
removed: "↩️ 已復原 {turns} 個回合({count} 則訊息)。\n已備份至:「{preview}」\n複製/編輯上方的文字並傳送,即可從此處重新提問。"
|
||||
invalid_count: "無效的數量「{arg}」—— 請使用 /undo 或 /undo N。"
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update 僅在訊息平台上可用。請在終端機執行 `hermes update`。"
|
||||
|
||||
@ -290,7 +290,8 @@ gateway:
|
||||
|
||||
undo:
|
||||
nothing: "没有可撤销的内容。"
|
||||
removed: "↩️ 已撤销 {count} 条消息。\n已移除:「{preview}」"
|
||||
removed: "↩️ 已撤销 {turns} 个回合({count} 条消息)。\n已备份到:「{preview}」\n复制/编辑上面的文本并发送,即可从此处重新提问。"
|
||||
invalid_count: "无效的数量「{arg}」—— 请使用 /undo 或 /undo N。"
|
||||
|
||||
update:
|
||||
platform_not_messaging: "✗ /update 仅在消息平台可用。请在终端运行 `hermes update`。"
|
||||
|
||||
82
tests/gateway/test_undo_rewind_session.py
Normal file
82
tests/gateway/test_undo_rewind_session.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Tests for SessionStore.rewind_session — the gateway /undo [N] primitive.
|
||||
|
||||
The gateway /undo backs up N user turns by soft-deleting the truncated rows
|
||||
in state.db (active=0, kept for audit, hidden from re-prompts/search) via
|
||||
SessionDB.rewind_to_message, rather than the old hard rewrite_transcript.
|
||||
load_transcript returns only the active view. See issue #21910.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.session import SessionStore
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
s = SessionStore(sessions_dir=tmp_path / "sessions", config=GatewayConfig())
|
||||
s._db = db # use the same DB instance the fixture seeds
|
||||
return s
|
||||
|
||||
|
||||
def _seed(store, sid, source="telegram", turns=3):
|
||||
store._db.create_session(sid, source=source)
|
||||
for i in range(1, turns + 1):
|
||||
store._db.append_message(sid, "user", f"q{i}")
|
||||
store._db.append_message(sid, "assistant", f"a{i}")
|
||||
return sid
|
||||
|
||||
|
||||
def test_rewind_default_one_turn(store):
|
||||
sid = _seed(store, "gw-1")
|
||||
res = store.rewind_session(sid)
|
||||
assert res["turns_undone"] == 1
|
||||
assert res["target_text"] == "q3"
|
||||
assert res["rewound_count"] == 2 # q3 + a3
|
||||
active = store.load_transcript(sid)
|
||||
assert [m["role"] for m in active] == ["user", "assistant", "user", "assistant"]
|
||||
|
||||
|
||||
def test_rewind_n_turns(store):
|
||||
sid = _seed(store, "gw-2")
|
||||
res = store.rewind_session(sid, 2)
|
||||
assert res["turns_undone"] == 2
|
||||
assert res["target_text"] == "q2"
|
||||
assert res["rewound_count"] == 4 # q2,a2,q3,a3
|
||||
assert len(store.load_transcript(sid)) == 2 # q1,a1
|
||||
|
||||
|
||||
def test_rewind_soft_deletes_rows_for_audit(store):
|
||||
sid = _seed(store, "gw-3")
|
||||
store.rewind_session(sid, 1)
|
||||
all_rows = store._db.get_messages(sid, include_inactive=True)
|
||||
assert len(all_rows) == 6 # nothing hard-deleted
|
||||
assert sum(1 for r in all_rows if r["active"] == 1) == 4
|
||||
assert store._db.get_session(sid)["rewind_count"] == 1
|
||||
|
||||
|
||||
def test_rewind_clamps_to_oldest_turn(store):
|
||||
sid = _seed(store, "gw-4", turns=2)
|
||||
res = store.rewind_session(sid, 99)
|
||||
assert res["target_text"] == "q1"
|
||||
assert len(store.load_transcript(sid)) == 0
|
||||
|
||||
|
||||
def test_rewind_empty_session_returns_none(store):
|
||||
store._db.create_session("gw-5", source="discord")
|
||||
assert store.rewind_session("gw-5") is None
|
||||
|
||||
|
||||
def test_rewind_clamps_negative_count_to_one(store):
|
||||
sid = _seed(store, "gw-6")
|
||||
res = store.rewind_session(sid, -5)
|
||||
assert res["turns_undone"] == 1
|
||||
assert res["target_text"] == "q3"
|
||||
Reference in New Issue
Block a user