From 1566f1eeccfffd3b72ac70777d70014bd050084a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 15:55:01 -0500 Subject: [PATCH] fix(tui): report actual session on exit --- hermes_cli/main.py | 29 +++++++++++++-- tests/hermes_cli/test_tui_resume_flow.py | 35 +++++++++++++++++++ .../src/__tests__/useSessionLifecycle.test.ts | 27 ++++++++++++++ ui-tui/src/app/useSessionLifecycle.ts | 16 +++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 ui-tui/src/__tests__/useSessionLifecycle.test.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e10af44cd9..968745704b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -44,6 +44,7 @@ Usage: """ import argparse +import json import os import shutil import subprocess @@ -760,9 +761,20 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: return None -def _print_tui_exit_summary(session_id: Optional[str]) -> None: +def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: + if not path: + return None + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + sid = str(data.get("session_id") or "").strip() + return sid or None + except Exception: + return None + + +def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None: """Print a shell-visible epilogue after TUI exits.""" - target = session_id or _resolve_last_session(source="tui") + target = _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui") if not target: return @@ -1037,7 +1049,13 @@ def _launch_tui( """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" + import tempfile + env = os.environ.copy() + active_session_file = os.path.join( + tempfile.gettempdir(), f"hermes-tui-active-session-{os.getpid()}.json" + ) + env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get( "HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT) ) @@ -1070,7 +1088,12 @@ def _launch_tui( code = 130 if code in (0, 130): - _print_tui_exit_summary(resume_session_id) + _print_tui_exit_summary(resume_session_id, active_session_file) + + try: + os.unlink(active_session_file) + except OSError: + pass sys.exit(code) diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 6044b04a4b..a8a2d3aa25 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -177,3 +177,38 @@ def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, ca assert "hermes --tui --resume 20260409_000001_abc123" in out assert 'hermes --tui -c "demo title"' in out assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out + + +def test_print_tui_exit_summary_prefers_actual_active_session_file(monkeypatch, capsys, tmp_path): + import hermes_cli.main as main_mod + + seen = [] + + class _FakeDB: + def get_session(self, session_id): + seen.append(session_id) + return { + "message_count": 1, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "reasoning_tokens": 0, + } + + def get_session_title(self, _session_id): + return "actual" + + def close(self): + return None + + active = tmp_path / "active.json" + active.write_text('{"session_id":"actual_session"}', encoding="utf-8") + monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())) + + main_mod._print_tui_exit_summary("startup_resume", str(active)) + out = capsys.readouterr().out + + assert seen == ["actual_session"] + assert "hermes --tui --resume actual_session" in out + assert "startup_resume" not in out diff --git a/ui-tui/src/__tests__/useSessionLifecycle.test.ts b/ui-tui/src/__tests__/useSessionLifecycle.test.ts new file mode 100644 index 0000000000..8d797742f2 --- /dev/null +++ b/ui-tui/src/__tests__/useSessionLifecycle.test.ts @@ -0,0 +1,27 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { writeActiveSessionFile } from '../app/useSessionLifecycle.js' + +describe('writeActiveSessionFile', () => { + let dir = '' + + afterEach(() => { + if (dir) { + rmSync(dir, { force: true, recursive: true }) + dir = '' + } + }) + + it('writes the actual resumed session id for the shell exit summary', () => { + dir = mkdtempSync(join(tmpdir(), 'hermes-tui-active-')) + const path = join(dir, 'active.json') + + writeActiveSessionFile('actual_session', path) + + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ session_id: 'actual_session' }) + }) +}) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index baaf3fc3c5..b475533a26 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,3 +1,5 @@ +import { writeFileSync } from 'node:fs' + import type { ScrollBoxHandle } from '@hermes/ink' import { type RefObject, useCallback } from 'react' @@ -22,6 +24,18 @@ import { getUiState, patchUiState } from './uiStore.js' const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) +export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => { + if (!file || !sessionId) { + return + } + + try { + writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 }) + } catch { + // Best-effort shell epilogue hint only; never break live session changes. + } +} + const trimTail = (items: Msg[]) => { const q = [...items] @@ -127,6 +141,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { resetSession() setSessionStartedAt(Date.now()) + writeActiveSessionFile(r.session_id) patchUiState({ info, sid: r.session_id, @@ -184,6 +199,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const resumed = toTranscriptMessages(r.messages) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + writeActiveSessionFile(r.resumed ?? r.session_id) patchUiState({ info: r.info ?? null, sid: r.session_id,