feat(tui): opt-in auto-resume of the most recent session (#17130)
* feat(tui): opt-in auto-resume of the most recent session
`hermes --tui` always forges a fresh session at startup unless the user
sets `HERMES_TUI_RESUME=<id>`. Disconnects, terminal-window crashes,
and accidental Ctrl+D therefore lose every piece of in-flight context
even though `state.db` still has the full history a `/resume` away.
Add an opt-in path that mirrors classic CLI's `hermes -c` muscle
memory: when `display.tui_auto_resume_recent: true` is set in
`~/.hermes/config.yaml`, the TUI looks up the most recent human-facing
session and resumes it instead of starting fresh. Default off so
existing users aren't surprised; explicit `HERMES_TUI_RESUME` always
wins.
Wires:
* New `session.most_recent` JSON-RPC in `tui_gateway/server.py` that
returns the first non-`tool` row from `list_sessions_rich`, or
`{"session_id": null}` when none. Uses the same deny-list as
`session.list` so sub-agent rows can't sneak in.
* `createGatewayEventHandler.handleReady` re-ordered: explicit
`STARTUP_RESUME_ID` first (unchanged), then conditional auto-resume
via `config.get full → display.tui_auto_resume_recent`, then the
legacy `newSession()` fallback. Failures of either RPC fall back
to `newSession()` so the path is always finite.
* Default `display.tui_auto_resume_recent: False` added to
`DEFAULT_CONFIG` in `hermes_cli/config.py` (no `_config_version`
bump per AGENTS.md — deep-merge handles the additive key).
Tests:
* 4 new vitest cases in `createGatewayEventHandler.test.ts` cover
every gate-and-fallback combination (env wins, config off, config
on with hit, config on with miss).
* 3 new pytest cases for `session.most_recent` (denied row skip,
tool-only → null, db-unavailable → null).
Validation:
scripts/run_tests.sh tests/test_tui_gateway_server.py — 93/93.
cd ui-tui && npm run type-check — clean; npm test --run — 393/393.
* review(copilot): fold session.most_recent errors into null + extend ConfigDisplayConfig
* review(copilot): cover RPC-rejection fallbacks in auto-resume tests
This commit is contained in:
@@ -2654,3 +2654,70 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
|
||||
)
|
||||
|
||||
mock_title.assert_not_called()
|
||||
|
||||
|
||||
# ── session.most_recent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_session_most_recent_returns_first_non_denied(monkeypatch):
|
||||
"""Drops `tool` rows like session.list does, returns the first hit."""
|
||||
|
||||
class _DB:
|
||||
def list_sessions_rich(self, *, source=None, limit=200):
|
||||
return [
|
||||
{"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100},
|
||||
{"id": "tui-1", "source": "tui", "title": "real", "started_at": 99},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.most_recent", "params": {}}
|
||||
)
|
||||
|
||||
assert resp["result"]["session_id"] == "tui-1"
|
||||
assert resp["result"]["title"] == "real"
|
||||
assert resp["result"]["source"] == "tui"
|
||||
|
||||
|
||||
def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch):
|
||||
class _DB:
|
||||
def list_sessions_rich(self, *, source=None, limit=200):
|
||||
return [{"id": "tool-1", "source": "tool", "started_at": 1}]
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.most_recent", "params": {}}
|
||||
)
|
||||
|
||||
assert resp["result"]["session_id"] is None
|
||||
|
||||
|
||||
def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch):
|
||||
"""Per contract, errors are folded into the null-result shape so
|
||||
callers don't have to special-case JSON-RPC error envelopes for
|
||||
'no answer' (Copilot review on #17130)."""
|
||||
|
||||
class _BrokenDB:
|
||||
def list_sessions_rich(self, *, source=None, limit=200):
|
||||
raise RuntimeError("db locked")
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.most_recent", "params": {}}
|
||||
)
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"]["session_id"] is None
|
||||
|
||||
|
||||
def test_session_most_recent_handles_db_unavailable(monkeypatch):
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.most_recent", "params": {}}
|
||||
)
|
||||
|
||||
assert resp["result"]["session_id"] is None
|
||||
|
||||
Reference in New Issue
Block a user