Skip to content

feat: /rewind v2 — gateway thread-scoped rewind + best-effort delete (#21910)#25074

Open
SaguaroDev wants to merge 12 commits into
NousResearch:mainfrom
SaguaroDev:feat/rewind-v2-gateway
Open

feat: /rewind v2 — gateway thread-scoped rewind + best-effort delete (#21910)#25074
SaguaroDev wants to merge 12 commits into
NousResearch:mainfrom
SaguaroDev:feat/rewind-v2-gateway

Conversation

@SaguaroDev

Copy link
Copy Markdown
Contributor

Stacked on #23445 (v1) — closes #21910 v2 scope

This PR extends /rewind to the gateway with the v2 spec from #21910:
best-effort message deletion across platforms, per-platform outbound-id
tracking, thread-scoped rewind, and a permission probe.

Base: feat/rewind-21910 (v1). Merge after v1.

What ships

Schema v13 (hermes_state.py)

Four nullable columns on messages for outbound tracking:

outbound_platform   TEXT
outbound_chat_id    TEXT
outbound_thread_id  TEXT
outbound_message_id TEXT

Partial index idx_messages_outbound on (session_id, outbound_chat_id) WHERE outbound_message_id IS NOT NULL keeps the rewind-delete lookup cheap.

Declarative reconcile adds the columns transparently on existing DBs; v12 → v13 migration block creates the index.

SessionDB primitives

  • set_outbound_ids(row_id, *, platform, chat_id, thread_id, message_id) — stamp coordinates onto a persisted assistant row. Silent no-op when message_id falsy.
  • get_inactive_outbound_ids(session_id, *, since_message_id, chat_id, thread_id) — lookup for delete-on-rewind, filterable by chat/thread so a single rewind never touches a sibling thread.
  • rewind_to_message(..., *, require_thread_scope=(chat_id, thread_id)) — optional kwarg that refuses cross-thread rewinds with ValueError. v1 callers (no kwarg) stay green; rows with NULL outbound coordinates bypass the guard.

Platform base (gateway/platforms/base.py)

  • can_delete_messages(chat_id) -> Optional[bool] — tri-state permission probe (True / False / None-unknown). Default returns None; adapters that can cheaply probe override.
  • _record_outbound_ids_for_send(event, result) — universal seam wired into _process_message_background's _record_delivery. After every successful send_with_retry, looks up the most recent assistant row and stamps (platform, chat_id, thread_id, message_id). Best-effort and defensively wrapped — a DB failure must never break message delivery.

Discord override (gateway/platforms/discord.py)

  • delete_messagediscord.Message.delete via REST DELETE. The bot can delete its own messages without manage_messages, which covers the v2 motivating use case (a wrong-thread bot post the user wants retracted).
  • can_delete_messages — probes channel reachability. Returns True when the channel resolves, False when it clearly doesn't, None on probe error.

Telegram override (gateway/platforms/telegram.py)

  • can_delete_messages — Telegram bots can always delete their own messages within 48h via Bot API deleteMessage, so returns True when the bot is connected. Aging-out is handled by delete_message returning False.

Gateway /rewind command (gateway/run.py)

Wired next to /branch. Auto-picks the most recent user message (single-step undo, mirroring v1 TUI; multi-message picker overlay deferred to v3). Per-turn:

  1. Resolve target = most-recent user message in active transcript.
  2. rewind_to_message(..., require_thread_scope=(chat, thread)) — soft-truncate. Surfaces Rewind refused: rewind would cross thread boundary if any in-scope row was posted elsewhere.
  3. Probe adapter.can_delete_messages once. When False, surfaces "Skipped N platform delete(s) — adapter reports no perms" instead of spamming the platform's API. When True/None, iterates adapter.delete_message over every recorded outbound id in scope.
  4. Aggregates deleted/attempted counts into the reply notice.
  5. Evicts cached agent and fires _memory_manager.on_session_switch(..., rewound=True) per v1's hook contract.

Test coverage

Suite Tests Status
tests/test_rewind_v2_state.py 12 new (schema v13, outbound roundtrip, chat/thread filter, active-row exclusion, thread-scope guard refuse/accept/NULL-bypass/backward-compat, v12→v13 migration)
tests/test_hermes_state.py 3 version-pin asserts bumped 12→13
Rewind domain combined 253 passed

Pre-existing gateway suite failures (test_config, test_tts_media_routing, test_verbose_command, test_update_streaming) reproduce on the v1 base without these changes — unrelated, tracked separately.

v2 spec coverage (from #21910)

  • Best-effort message deletiondelete_message called per outbound row; failures swallowed.
  • Per-platform message ID tracking — uniform via set_outbound_ids wired through _record_delivery.
  • Permission probecan_delete_messages tri-state with user-visible aggregation.
  • Thread-scoped only — enforced in DB primitive (require_thread_scope) so it can't be bypassed from the gateway.

Adapter rollout

Discord (the motivating wrong-channel-post case) and Telegram are fully covered. Other platforms (Slack, Matrix, Mattermost, WhatsApp, Signal, Feishu, etc.) inherit the base defaults — they keep the in-memory rewind but skip the delete step until their adapter overrides delete_message. The pattern is the two-method override shown here; opt-in is incremental and risk-free.

CC

SaguaroDev and others added 10 commits May 11, 2026 21:38
…ch#21910)

Schema v12 adds:
- messages.active (default 1) — soft-delete flag for /rewind
- sessions.rewind_count (default 0) — audit counter
- idx_messages_session_active deferred index

New SessionDB methods:
- rewind_to_message(session_id, target_message_id) — soft-deletes rows
  >= target_id, refuses non-user targets, increments rewind_count
- restore_rewound(session_id, since_message_id) — undo for stretch goal
- list_recent_user_messages — picker source

Existing methods get include_inactive kwarg (default False):
- get_messages, get_messages_as_conversation, search_messages.
  Rewound rows excluded from session_search by default — opt-in for audit.

The deferred index pattern (DEFERRED_INDEX_SQL run after _reconcile_columns)
avoids 'no such column: active' on legacy pre-v12 databases, since
executescript(SCHEMA_SQL) runs before column reconciliation.
Adding rewound as an explicit named kwarg on MemoryManager.on_session_switch
broke existing tests that assert exact equality on the extra kwargs dict
captured by providers (test_manager_fans_out_to_all_providers,
test_manager_reset_flag_preserved).

The MemoryProvider base class still documents rewound as a known kwarg,
but the manager now passes it through **kwargs like any other forwarded
flag. The CLI rewind handler already passes rewound=True keyword-style,
so behavior is identical.
…ousResearch#21910)

Adds the TUI half of the /rewind feature so the Ink terminal UI gets
the same affordance as the prompt_toolkit CLI.

Python side (tui_gateway/server.py):
- /rewind added to _PENDING_INPUT_COMMANDS so slash.exec rejects it
  and the TUI falls through to command.dispatch (the only path with
  access to live session state + memory hooks).
- New command.dispatch branch for name == "rewind":
  v1 auto-picks the most recent user turn (Claude-Code-style single-
  step undo), calls SessionDB.rewind_to_message, refreshes the
  in-memory history, fires _memory_manager.on_session_switch with
  rewound=True, and returns the new "prefill" payload.
- A dedicated picker overlay (multi-step rewind) is tracked as a
  follow-up to NousResearch#21910.

TS side (ui-tui/src/):
- New "prefill" variant on CommandDispatchResponse + asCommandDispatch
  validator. Mirrors "send" but does NOT auto-submit; the client drops
  the message into the composer for editing.
- createSlashHandler renders the optional notice via sys() and calls
  ctx.composer.setInput(d.message), letting the user edit-and-resubmit
  the rewound turn — the core UX promised by the issue.

Tests:
- 7 new tui_gateway tests covering prefill payload shape, in-memory
  history truncation, DB soft-delete, memory-provider notification
  (rewound=True), busy-session refusal, missing-session error, and
  registry placement in _PENDING_INPUT_COMMANDS.
- Extended asCommandDispatch vitest covering the new prefill variant
  (with + without notice, and rejection of malformed payloads).

Out of scope for v1 (tracked as NousResearch#21910 follow-up):
- Dedicated picker overlay in Ink (the multi-step rewind UI). v1 auto-
  picks the most recent user turn, matching the most common case.
- Gateway platforms (Telegram, Discord, etc.) — issue scopes v1 to
  CLI + TUI only.
…ousResearch#21910)

Adds nullable columns to messages for gateway outbound-message tracking
so /rewind v2 can best-effort delete the bot's public posts when the
in-memory context is truncated:

  outbound_platform   TEXT  — e.g. 'discord', 'telegram'
  outbound_chat_id    TEXT  — platform chat/channel id
  outbound_thread_id  TEXT  — thread/topic id (nullable for flat platforms)
  outbound_message_id TEXT  — the platform message id to delete

New SessionDB primitives:

  set_outbound_ids(row_id, *, platform, chat_id, thread_id, message_id)
      Stamp coordinates onto a persisted assistant row.  Silent no-op
      when message_id is falsy.

  get_inactive_outbound_ids(session_id, *, since_message_id, chat_id, thread_id)
      Lookup helper used by the gateway rewind code path to drive
      best-effort platform.delete_message calls.  Filters scope to
      caller's (chat, thread) so a single rewind never deletes
      messages in a sibling thread.

  rewind_to_message(..., *, require_thread_scope=(chat_id, thread_id))
      Optional kwarg that refuses cross-thread rewinds with ValueError.
      v1 callers (CLI/TUI, no kwarg) stay green — the guard only fires
      when explicitly passed.  Rows missing outbound coordinates are
      not subject to the guard (NULL bypass).

Migration v12 → v13:

  - Declarative reconcile adds the columns transparently.
  - Partial index idx_messages_outbound on (session_id, outbound_chat_id)
    WHERE outbound_message_id IS NOT NULL — created after reconcile so
    legacy DBs don't trip on the missing column.

Tests:
  - test_rewind_v2_state.py: 12 new (schema, set/get roundtrip,
    chat/thread filtering, active-row exclusion, thread-scope guard
    refuse/accept/NULL-bypass/backward-compat, full v12→v13 migration).
  - test_hermes_state.py: bump 3 version-pin asserts to 13.
…ng seam

Two additions to BasePlatform for /rewind v2 (NousResearch#21910):

1. can_delete_messages(chat_id) -> Optional[bool]
   Best-effort permission probe.  Tri-state return:
   - True   adapter has (or always has) deletion in this chat
   - False  known not to have permission / no delete API
   - None   unknown — caller falls back to optimism
   Default returns None.  Adapters that can cheaply probe (Discord
   channel reachability, Telegram bot connectedness) override.

2. _record_outbound_ids_for_send(event, result)
   Universal seam wired into _process_message_background's
   _record_delivery closure.  Stamps (platform, chat_id, thread_id,
   message_id) onto the most recent assistant row in SessionDB after
   every successful send_with_retry — so when a user runs /rewind in
   a Discord thread or Telegram topic, the gateway knows which
   platform messages to attempt to delete.

   Best-effort and defensively wrapped: any failure (no session
   store, no SessionDB, no message_id, no recent assistant row) is
   logged at debug and swallowed.  A DB hiccup must never break
   message delivery.

   Multi-bubble responses stamp the last-visible id onto the same
   row — acceptable for v2 since the rewind UI is binary
   (delete-attempted vs. not); continuation_message_ids tracking
   is a v3 follow-up.

Also extends delete_message docstring with the v2 use case.
Discord (gateway/platforms/discord.py):
  - delete_message: discord.Message.delete via REST DELETE.  The bot
    can delete its own messages without manage_messages perms, which
    covers the /rewind v2 use case (a wrong-thread bot post the user
    wants retracted).  Failures (gone, perms, rate limit) return False
    so callers degrade gracefully.
  - can_delete_messages: probes channel reachability.  Returns True
    when the channel resolves, False when it clearly doesn't (bot
    kicked, channel deleted), None when the probe itself errors.

Telegram (gateway/platforms/telegram.py):
  - can_delete_messages: bot can always delete its own messages
    within 48h via Bot API deleteMessage, so returns True whenever
    the bot is connected.  Aging-out is handled by the existing
    delete_message implementation returning False.

Closes the platform-adapter slice of /rewind v2 (NousResearch#21910).  Other
platforms (Slack, Matrix, WhatsApp, etc.) inherit the base defaults
(can_delete_messages -> None, delete_message -> False) and can opt
in incrementally with the same pattern.
…lete

Wires /rewind into the gateway slash dispatch next to /branch.  The
gateway version mirrors v1 TUI's single-step auto-pick behavior — a
multi-message picker overlay is a v3 follow-up.

Per-turn flow:
  1. Resolve target = most-recent user message in active transcript.
  2. SessionDB.rewind_to_message(..., require_thread_scope=(chat, thread))
     soft-deletes everything at-or-after that turn.  Refuses with a
     user-visible 'Rewind refused' notice if any in-scope row was
     posted into a different (chat_id, thread_id) — keeps the blast
     radius predictable per the v2 spec.
  3. Probes adapter.can_delete_messages once.  When False (adapter
     reports no perms), surfaces a 'Skipped N platform deletes' notice
     instead of spamming the platform's logs.  When True/None, iterates
     adapter.delete_message over every recorded outbound id in scope.
  4. Aggregates deleted/attempted counts into the reply notice.
  5. Evicts cached agent and fires _memory_manager.on_session_switch
     (..., rewound=True) per v1's hook contract.

Closes the gateway slice of /rewind v2 (NousResearch#21910).  Discord (the
motivating wrong-channel-post case) is fully covered; other platforms
degrade gracefully — they keep the in-memory rewind but skip the
delete step until their adapter overrides delete_message.
@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/cli CLI entry point, hermes_cli/, setup wizard labels May 13, 2026
Conflicts resolved in hermes_state.py and tests/test_hermes_state.py:

- SCHEMA_VERSION: kept 13 (this branch's bump for /rewind v2 outbound
  tracking); main's PR NousResearch#27660 had bumped to 12 for platform_message_id.
- messages table: kept both column sets — outbound_platform/chat_id/
  thread_id/message_id (v13, /rewind delete) AND platform_message_id
  (v12, yuanbao recall exact-id match). Declarative reconciler adds
  both to legacy DBs.
- DEFERRED_INDEX_SQL: added main's partial index
  idx_messages_platform_msg_id alongside the existing
  idx_messages_session_active.
- get_messages / get_messages_as_conversation: kept this branch's
  include_inactive=False soft-delete filter; adopted main's ORDER BY id
  (commit c03acca — AUTOINCREMENT id is the canonical ordering, not
  timestamp). get_messages_as_conversation SELECT now includes
  platform_message_id so the row['platform_message_id'] reader added
  in main works.
- search_messages: kept both new kwargs (include_inactive=False AND
  sort=None) and both docstring sections. Bodies already compose
  cleanly.
- test_hermes_state.py: switched the three '== 13' literals to
  '== SCHEMA_VERSION' (main's form) so the test moves with future
  bumps.
@SaguaroDev SaguaroDev marked this pull request as ready for review May 20, 2026 20:46
# Conflicts:
#	hermes_state.py
#	tests/test_hermes_state.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: rewind/edit-and-resubmit (like Claude Code's double-Esc)

2 participants