diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7fdc4d7e07..a0222391e2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -703,6 +703,11 @@ DEFAULT_CONFIG = { "personality": "kawaii", "resume_display": "full", "busy_input_mode": "interrupt", # interrupt | queue | steer + # When true, `hermes --tui` auto-resumes the most recent human- + # facing session on launch instead of forging a fresh one. + # Mirrors `hermes -c` muscle memory. Default off so existing + # users aren't surprised. HERMES_TUI_RESUME= always wins. + "tui_auto_resume_recent": False, "bell_on_complete": False, "show_reasoning": False, "streaming": False, diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4a7b9e6cc1..271214fa39 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8a356c9f1e..fc70168144 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1788,6 +1788,50 @@ def _(rid, params: dict) -> dict: return _err(rid, 5006, str(e)) +@method("session.most_recent") +def _(rid, params: dict) -> dict: + """Return the most recent human-facing session id, or ``None``. + + Mirrors ``session.list``'s deny-list behaviour (drops ``tool`` + sub-agent rows). Used by TUI auto-resume when + ``display.tui_auto_resume_recent`` is on; the field is also handy + for any CLI tooling that wants "latest session" without paginating + the full list. + + Contract: a ``{"session_id": null}`` result means "no eligible + session found right now". Errors are also folded into that + null-result shape (and logged) so callers don't have to special- + case JSON-RPC error envelopes for what is a normal "no answer". + """ + db = _get_db() + if db is None: + return _ok(rid, {"session_id": None}) + try: + deny = frozenset({"tool"}) + # Over-fetch by a generous bounded amount so heavy sub-agent + # users (lots of recent ``tool`` rows) don't get a false + # "no eligible session" answer. ``session.list`` uses a + # similar over-fetch strategy. + rows = db.list_sessions_rich(source=None, limit=200) + for row in rows: + src = (row.get("source") or "").strip().lower() + if src in deny: + continue + return _ok( + rid, + { + "session_id": row.get("id"), + "title": row.get("title") or "", + "started_at": row.get("started_at") or 0, + "source": row.get("source") or "", + }, + ) + return _ok(rid, {"session_id": None}) + except Exception: + logger.exception("session.most_recent failed") + return _ok(rid, {"session_id": None}) + + @method("session.resume") def _(rid, params: dict) -> dict: target = params.get("session_id", "") diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index d9f147c7a5..ffda1055a0 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -458,6 +458,152 @@ describe('createGatewayEventHandler', () => { }) }) + it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = '' + ctx.gateway.rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { config: { display: { tui_auto_resume_recent: false } } } + } + + return null + }) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(newSession).toHaveBeenCalled()) + expect(resumeById).not.toHaveBeenCalled() + }) + + it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = '' + ctx.gateway.rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { config: { display: { tui_auto_resume_recent: true } } } + } + + if (method === 'session.most_recent') { + return { session_id: 'sess-most-recent' } + } + + return null + }) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent')) + expect(newSession).not.toHaveBeenCalled() + }) + + it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = '' + ctx.gateway.rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { config: { display: { tui_auto_resume_recent: true } } } + } + + if (method === 'session.most_recent') { + return { session_id: null } + } + + return null + }) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(newSession).toHaveBeenCalled()) + expect(resumeById).not.toHaveBeenCalled() + }) + + it('on gateway.ready when config.get rejects, falls back to new session', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = '' + ctx.gateway.rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + throw new Error('gateway timeout') + } + + return null + }) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(newSession).toHaveBeenCalled()) + expect(resumeById).not.toHaveBeenCalled() + }) + + it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = '' + ctx.gateway.rpc = vi.fn(async (method: string) => { + if (method === 'config.get') { + return { config: { display: { tui_auto_resume_recent: true } } } + } + + if (method === 'session.most_recent') { + throw new Error('db locked') + } + + return null + }) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(newSession).toHaveBeenCalled()) + expect(resumeById).not.toHaveBeenCalled() + }) + + it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => { + const appended: Msg[] = [] + const newSession = vi.fn() + const resumeById = vi.fn() + const ctx = buildCtx(appended) + + ctx.session.newSession = newSession + ctx.session.resumeById = resumeById + ctx.session.STARTUP_RESUME_ID = 'env-explicit' + ctx.gateway.rpc = vi.fn(async () => ({ + config: { display: { tui_auto_resume_recent: true } } + })) + + createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any) + + await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit')) + expect(newSession).not.toHaveBeenCalled() + }) + it('keeps gateway noise informational and approval out of Activity', async () => { const appended: Msg[] = [] const ctx = buildCtx(appended) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d36faa336c..3abfc18561 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,13 @@ import { STREAM_BATCH_MS } from '../config/timing.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' -import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' +import type { + CommandsCatalogResponse, + ConfigFullResponse, + DelegationStatusResponse, + GatewayEvent, + GatewaySkin, + SessionMostRecentResponse +} from '../gatewayTypes.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' import { formatToolCall, stripAnsi } from '../lib/text.js' @@ -171,15 +178,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: }) .catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'info')) - if (!STARTUP_RESUME_ID) { - patchUiState({ status: 'forging session…' }) - newSession() + if (STARTUP_RESUME_ID) { + patchUiState({ status: 'resuming…' }) + resumeById(STARTUP_RESUME_ID) return } - patchUiState({ status: 'resuming…' }) - resumeById(STARTUP_RESUME_ID) + // Opt-in: when `display.tui_auto_resume_recent` is true, look up + // the most recent human-facing session and resume it instead of + // forging a brand-new one. Mirrors classic CLI's `hermes -c` / + // `hermes --tui` muscle memory and addresses the audit's "session + // unrecoverable after disconnection" gap. Default off so existing + // users aren't surprised. + rpc('config.get', { key: 'full' }) + .then(cfg => { + if (!cfg?.config?.display?.tui_auto_resume_recent) { + patchUiState({ status: 'forging session…' }) + newSession() + + return + } + + return rpc('session.most_recent', {}).then(r => { + const target = r?.session_id + + if (target) { + patchUiState({ status: 'resuming most recent…' }) + resumeById(target) + + return + } + + patchUiState({ status: 'forging session…' }) + newSession() + }) + }) + .catch(() => { + patchUiState({ status: 'forging session…' }) + newSession() + }) } return (ev: GatewayEvent) => { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 5a7f8d8ad1..6a4fa2ae02 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -60,6 +60,7 @@ export interface ConfigDisplayConfig { show_reasoning?: boolean streaming?: boolean thinking_mode?: string + tui_auto_resume_recent?: boolean tui_compact?: boolean tui_mouse?: boolean tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean @@ -119,6 +120,13 @@ export interface SessionListResponse { sessions?: SessionListItem[] } +export interface SessionMostRecentResponse { + session_id?: null | string + source?: string + started_at?: number + title?: string +} + export interface SessionTitleResponse { pending?: boolean session_key?: string