mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 02:01:47 +00:00
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
6 changed files with 314 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue