From 4e819c97ec133660a7fe8d030071398f1ba450ab Mon Sep 17 00:00:00 2001 From: Tzuchieh Lin Date: Fri, 31 Jul 2026 00:57:49 +0800 Subject: [PATCH] fix(tui): support /quit --delete to remove session on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI's /quit handler ignored its argument entirely — the --delete flag added for the CLI in #27101 never reached any deletion logic. Add a session.exit gateway RPC that mirrors the CLI shutdown path (cli.py:17317): deletes the session's SQLite rows and on-disk transcripts. Unlike session.delete (resume picker), it bypasses the active-session guard because the caller is about to terminate. The frontend now parses --delete / -d, rejects unknown flags with a usage hint, and calls session.exit before ctx.session.die(). Deletion is best-effort (.finally) — the TUI exits regardless, matching the CLI's try/except behavior. --- tests/test_tui_gateway_server.py | 141 +++++++++++++++++++++++ tui_gateway/methods_session.py | 36 ++++++ ui-tui/src/app/slash/commands/core.ts | 22 +++- website/docs/reference/slash-commands.md | 6 +- 4 files changed, 200 insertions(+), 5 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ada479fafbbfa..dcb014f84e5f8 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -10495,6 +10495,147 @@ def delete_session(self, sid, sessions_dir=None): +# -------------------------------------------------------------------------- +# session.exit — TUI /quit --delete (mirrors CLI /exit --delete, #27101) +# -------------------------------------------------------------------------- + + +def test_session_exit_requires_session_id(monkeypatch): + """Empty / missing session_id is a 4006 client error (no DB call).""" + called: list[tuple] = [] + + class _DB: + def delete_session(self, *a, **kw): + called.append((a, kw)) + return True + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"delete": True}} + ) + assert "error" in resp + assert resp["error"]["code"] == 4006 + assert called == [] + + +def test_session_exit_noop_without_delete_flag(monkeypatch): + """Without ``delete: true`` the RPC is a no-op (no DB call).""" + called: list[tuple] = [] + + class _DB: + def delete_session(self, *a, **kw): + called.append((a, kw)) + return True + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"session_id": "abc"}} + ) + assert "result" in resp + assert resp["result"] == {"deleted": None} + assert called == [] + + +def test_session_exit_returns_db_unavailable_when_no_db(monkeypatch): + monkeypatch.setattr(server, "_get_db", lambda: None) + monkeypatch.setattr(server, "_db_error", "locked") + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"session_id": "abc", "delete": True}} + ) + + assert "error" in resp + assert resp["error"]["code"] == 5036 + assert "state.db unavailable" in resp["error"]["message"] + + +def test_session_exit_allows_active_session(monkeypatch): + """Unlike session.delete, session.exit MUST allow deleting the active + session — the caller is about to terminate the process.""" + called: list[str] = [] + + class _DB: + def delete_session(self, sid, sessions_dir=None): + called.append(sid) + return True + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setitem(server._sessions, "live", {"session_key": "key-live"}) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.exit", + "params": {"session_id": "key-live", "delete": True}, + } + ) + finally: + server._sessions.pop("live", None) + + assert "result" in resp, resp + assert resp["result"] == {"deleted": "key-live"} + assert called == ["key-live"] + + +def test_session_exit_returns_4007_when_missing(monkeypatch): + class _DB: + def delete_session(self, sid, sessions_dir=None): + return False + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"session_id": "ghost", "delete": True}} + ) + + assert "error" in resp + assert resp["error"]["code"] == 4007 + + +def test_session_exit_propagates_db_exception(monkeypatch): + class _DB: + def delete_session(self, sid, sessions_dir=None): + raise RuntimeError("disk full") + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"session_id": "x", "delete": True}} + ) + + assert "error" in resp + assert resp["error"]["code"] == 5036 + assert "disk full" in resp["error"]["message"] + + +def test_session_exit_success_returns_deleted_id(monkeypatch): + """Happy path — DB delete succeeds, response carries the deleted id + and the on-disk sessions dir is forwarded so transcript files get + cleaned up alongside the row.""" + captured: dict = {} + + class _DB: + def delete_session(self, sid, sessions_dir=None): + captured["sid"] = sid + captured["sessions_dir"] = sessions_dir + return True + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + + resp = server.handle_request( + {"id": "1", "method": "session.exit", "params": {"session_id": "old-1", "delete": True}} + ) + + assert "result" in resp, resp + assert resp["result"] == {"deleted": "old-1"} + assert captured["sid"] == "old-1" + assert captured["sessions_dir"] is not None + assert str(captured["sessions_dir"]).endswith("sessions") + + + # -------------------------------------------------------------------------- # session.* profile scoping (app-global remote mode) — #62503 # -------------------------------------------------------------------------- diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 5b00a42a51da8..0e59cf9c5d450 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -835,6 +835,42 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"deleted": target}) +@method("session.exit") +def _(rid, params: dict) -> dict: + """Graceful exit with optional session deletion (TUI ``/quit --delete``). + + Mirrors the CLI's ``/exit --delete`` shutdown path (cli.py:17317): + deletes the session's SQLite rows and on-disk transcript files. The + active-session guard in ``session.delete`` is intentionally bypassed + here because the caller is about to terminate the process — no further + writes will land after this RPC returns. + + Called by the TUI frontend *before* ``ctx.session.die()`` so the + deletion completes while the gateway is still alive. + """ + target = params.get("session_id", "") + if not target: + return _err(rid, 4006, "session_id required") + if not params.get("delete"): + return _ok(rid, {"deleted": None}) + profile = (params.get("profile") or "").strip() or None + profile_home = _profile_home(profile) + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5036) + if profile_home is not None: + sessions_dir = Path(profile_home) / "sessions" + else: + sessions_dir = get_hermes_home() / "sessions" + try: + deleted = db.delete_session(target, sessions_dir=sessions_dir) + except Exception as e: + return _err(rid, 5036, f"delete failed: {e}") + if not deleted: + return _err(rid, 4007, "session not found") + return _ok(rid, {"deleted": target}) + + @method("session.title") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 00321ecc9046d..4d26f3f335f95 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -128,7 +128,7 @@ export const coreCommands: SlashCommand[] = [ aliases: ['exit'], help: 'exit hermes', name: 'quit', - run: (_arg, ctx) => { + run: (arg, ctx) => { // In the hosted dashboard chat there is no in-page restart path after // the PTY child exits, so quitting bricks the tab until a refresh. The // keyboard idle-exit (Ctrl+C / Ctrl+D) and SIGINT handling already refuse @@ -142,7 +142,25 @@ export const coreCommands: SlashCommand[] = [ return } - ctx.session.die() + // /quit --delete: remove this session's transcripts + SQLite history + // before exiting, mirroring the CLI's /exit --delete (cli.py:9588). + const flag = arg.trim().toLowerCase() + + if (flag && flag !== '--delete' && flag !== '-d') { + ctx.transcript.sys('usage: /quit [--delete]') + + return + } + + if (flag && ctx.sid) { + // Ask the gateway to delete the session while it's still alive, + // then exit regardless of the outcome (best-effort, like the CLI). + ctx.gateway + .rpc('session.exit', { delete: true, session_id: ctx.sid }) + .finally(() => ctx.session.die()) + } else { + ctx.session.die() + } } }, diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index ae38e858f6a12..e3a29b074c4a2 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -139,7 +139,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/quit` | Exit the CLI (also: `/exit`). | +| `/quit` | Exit the CLI or TUI (also: `/exit`). Pass `--delete` (or `-d`) — e.g. `/exit --delete` — to permanently delete the current session's SQLite history and on-disk transcripts before exiting. Works in both the classic CLI and the TUI. | ### Dynamic CLI slash commands @@ -281,7 +281,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/busy`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/busy`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, and `/subscription` are **CLI-only** commands. - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. @@ -299,7 +299,7 @@ The CLI prompts before running slash commands that throw away unsaved session st | `/clear` | Clears the screen and starts a fresh session — current session ID and in-memory history are gone. | | `/new` / `/reset` | Starts a fresh session (new session ID + empty history). | | `/undo` | Removes the last user/assistant exchange from history. | -| `/exit --delete` / `/quit --delete` | Exits **and** permanently deletes the current session's SQLite history and on-disk transcripts. | +| `/exit --delete` / `/quit --delete` | Exits **and** permanently deletes the current session's SQLite history and on-disk transcripts. Works in both the classic CLI and the TUI. | For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**.