Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 20 additions & 2 deletions ui-tui/src/app/slash/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
},

Expand Down
6 changes: 3 additions & 3 deletions website/docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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**.

Expand Down