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:
parent
75d9811393
commit
87d3fa6f1c
@ -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=<id> always wins.
|
||||
"tui_auto_resume_recent": False,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<ConfigFullResponse>('config.get', { key: 'full' })
|
||||
.then(cfg => {
|
||||
if (!cfg?.config?.display?.tui_auto_resume_recent) {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return rpc<SessionMostRecentResponse>('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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user