mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(cli): -c picks the most recently used session
This commit is contained in:
parent
b36007b246
commit
bde89c169b
4 changed files with 183 additions and 14 deletions
|
|
@ -596,15 +596,32 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||||
|
|
||||||
|
|
||||||
def _resolve_last_session(source: str = "cli") -> Optional[str]:
|
def _resolve_last_session(source: str = "cli") -> Optional[str]:
|
||||||
"""Look up the most recent session ID for a source."""
|
"""Look up the most recently *used* session ID for a source.
|
||||||
|
|
||||||
|
Previously this returned the most recently *started* session, which meant
|
||||||
|
`hermes -c` could skip the session you just closed if a newer one had been
|
||||||
|
opened earlier in a different window. We now order by last_active
|
||||||
|
(max message timestamp, falling back to started_at) so -c always resumes
|
||||||
|
the most recent conversation you actually touched.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
|
|
||||||
db = SessionDB()
|
db = SessionDB()
|
||||||
sessions = db.search_sessions(source=source, limit=1)
|
sessions = db.search_sessions(source=source, limit=20)
|
||||||
db.close()
|
db.close()
|
||||||
if sessions:
|
if not sessions:
|
||||||
return sessions[0]["id"]
|
return None
|
||||||
|
|
||||||
|
def _last_active(s: dict) -> float:
|
||||||
|
v = s.get("last_active") or s.get("started_at") or 0
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
sessions.sort(key=_last_active, reverse=True)
|
||||||
|
return sessions[0]["id"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
61
tests/hermes_cli/test_resolve_last_session.py
Normal file
61
tests/hermes_cli/test_resolve_last_session.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Verify `hermes -c` picks the session the user most recently used."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hermes_cli.main import _resolve_last_session
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def search_sessions(self, source=None, limit=20, **_kw):
|
||||||
|
rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows)
|
||||||
|
return rows[:limit]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_last_session_prefers_last_active_over_started_at(monkeypatch):
|
||||||
|
# `search_sessions` returns in started_at DESC order, but the most recently
|
||||||
|
# *touched* session may have been started earlier. -c should pick by
|
||||||
|
# last_active so closing the active session and typing `hermes -c` resumes
|
||||||
|
# that one, not an older-but-newer-started session from another window.
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": "new_started_old_active",
|
||||||
|
"source": "cli",
|
||||||
|
"started_at": 1000.0,
|
||||||
|
"last_active": 100.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "old_started_recently_active",
|
||||||
|
"source": "cli",
|
||||||
|
"started_at": 500.0,
|
||||||
|
"last_active": 999.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_db = _FakeDB(rows)
|
||||||
|
monkeypatch.setattr("hermes_state.SessionDB", lambda: fake_db)
|
||||||
|
|
||||||
|
assert _resolve_last_session("cli") == "old_started_recently_active"
|
||||||
|
assert fake_db.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_last_session_returns_none_when_empty(monkeypatch):
|
||||||
|
monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB([]))
|
||||||
|
assert _resolve_last_session("cli") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_last_session_falls_back_to_started_at(monkeypatch):
|
||||||
|
# When last_active is missing entirely (legacy row), fall back to
|
||||||
|
# started_at so the helper still picks the newest session.
|
||||||
|
rows = [
|
||||||
|
{"id": "older", "source": "cli", "started_at": 10.0},
|
||||||
|
{"id": "newer", "source": "cli", "started_at": 20.0},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr("hermes_state.SessionDB", lambda: _FakeDB(rows))
|
||||||
|
assert _resolve_last_session("cli") == "newer"
|
||||||
|
|
@ -8,6 +8,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||||
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||||
|
import { PerfPane } from '../lib/perfPane.js'
|
||||||
|
|
||||||
import { AgentsOverlay } from './agentsOverlay.js'
|
import { AgentsOverlay } from './agentsOverlay.js'
|
||||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||||
|
|
@ -248,23 +249,31 @@ export const AppLayout = memo(function AppLayout({
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<Box flexDirection="row" flexGrow={1}>
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
{overlay.agents ? (
|
{overlay.agents ? (
|
||||||
<AgentsOverlayPane />
|
<PerfPane id="agents">
|
||||||
|
<AgentsOverlayPane />
|
||||||
|
</PerfPane>
|
||||||
) : (
|
) : (
|
||||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
<PerfPane id="transcript">
|
||||||
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||||
|
</PerfPane>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!overlay.agents && (
|
{!overlay.agents && (
|
||||||
<>
|
<>
|
||||||
<PromptZone
|
<PerfPane id="prompt">
|
||||||
cols={composer.cols}
|
<PromptZone
|
||||||
onApprovalChoice={actions.answerApproval}
|
cols={composer.cols}
|
||||||
onClarifyAnswer={actions.answerClarify}
|
onApprovalChoice={actions.answerApproval}
|
||||||
onSecretSubmit={actions.answerSecret}
|
onClarifyAnswer={actions.answerClarify}
|
||||||
onSudoSubmit={actions.answerSudo}
|
onSecretSubmit={actions.answerSecret}
|
||||||
/>
|
onSudoSubmit={actions.answerSudo}
|
||||||
|
/>
|
||||||
|
</PerfPane>
|
||||||
|
|
||||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
<PerfPane id="composer">
|
||||||
|
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||||
|
</PerfPane>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
82
ui-tui/src/lib/perfPane.tsx
Normal file
82
ui-tui/src/lib/perfPane.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Perf instrumentation: wraps React.Profiler around named panes and writes
|
||||||
|
// commit timings to a log file when HERMES_DEV_PERF is set. Enabled per-run
|
||||||
|
// via the env var; zero-cost (Profiler is replaced by a Fragment) when off.
|
||||||
|
//
|
||||||
|
// Log format: one JSON object per line, for easy `jq` filtering. We only
|
||||||
|
// log commits that exceed a threshold (default 2ms) so the file doesn't
|
||||||
|
// fill up with sub-millisecond idle renders. Tune via HERMES_DEV_PERF_MS.
|
||||||
|
//
|
||||||
|
// Usage in consumers:
|
||||||
|
// import { PerfPane } from './perfPane.js'
|
||||||
|
// <PerfPane id="transcript"> ... </PerfPane>
|
||||||
|
//
|
||||||
|
// Inspect with:
|
||||||
|
// tail -f ~/.hermes/perf.log | jq -c 'select(.actualMs > 8)'
|
||||||
|
// jq -s 'group_by(.id) | map({id: .[0].id, count: length, p50: (sort_by(.actualMs) | .[length/2|floor].actualMs), p99: (sort_by(.actualMs) | .[length*0.99|floor].actualMs)})' ~/.hermes/perf.log
|
||||||
|
|
||||||
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
|
||||||
|
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 2
|
||||||
|
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
const ensureLogDir = () => {
|
||||||
|
if (initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(LOG_PATH), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.)
|
||||||
|
// the appendFileSync calls below will throw silently and we drop the
|
||||||
|
// sample. Perf logging should never crash the TUI.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => {
|
||||||
|
if (actualMs < THRESHOLD_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLogDir()
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
actualMs: Math.round(actualMs * 100) / 100,
|
||||||
|
baseMs: Math.round(baseMs * 100) / 100,
|
||||||
|
commitMs: Math.round(commitTime * 100) / 100,
|
||||||
|
id,
|
||||||
|
phase,
|
||||||
|
startMs: Math.round(startTime * 100) / 100,
|
||||||
|
ts: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
|
||||||
|
} catch {
|
||||||
|
// Same rationale as ensureLogDir — never crash the UI to log a sample.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PerfPane({ children, id }: { children: ReactNode; id: string }) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Profiler id={id} onRender={onRender}>
|
||||||
|
{children}
|
||||||
|
</Profiler>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERF_ENABLED = ENABLED
|
||||||
|
export const PERF_LOG_PATH = LOG_PATH
|
||||||
Loading…
Add table
Add a link
Reference in a new issue