mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
feat: support numbered resume selection in cli and gateway
This commit is contained in:
parent
4f4e337c47
commit
fef733d56b
5 changed files with 164 additions and 21 deletions
31
cli.py
31
cli.py
|
|
@ -6172,15 +6172,16 @@ class HermesCLI:
|
|||
else:
|
||||
print(" Recent sessions:")
|
||||
print()
|
||||
print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||
print(f" {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
|
||||
for session in sessions:
|
||||
print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||
print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
|
||||
for idx, session in enumerate(sessions, start=1):
|
||||
title = session.get("title") or "—"
|
||||
preview = (session.get("preview") or "")[:38]
|
||||
last_active = _relative_time(session.get("last_active"))
|
||||
print(f" {title:<32} {preview:<40} {last_active:<13} {session['id']}")
|
||||
print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
|
||||
print()
|
||||
print(" Use /resume <session id or title> to continue where you left off.")
|
||||
print(" Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
|
||||
print(" Example: /resume 2")
|
||||
print()
|
||||
return True
|
||||
|
||||
|
|
@ -6525,7 +6526,7 @@ class HermesCLI:
|
|||
target = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if not target:
|
||||
_cprint(" Usage: /resume <session_id_or_title>")
|
||||
_cprint(" Usage: /resume <number|session_id_or_title>")
|
||||
if self._show_recent_sessions(reason="resume"):
|
||||
return
|
||||
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
||||
|
|
@ -6536,10 +6537,20 @@ class HermesCLI:
|
|||
_cprint(f" {format_session_db_unavailable()}")
|
||||
return
|
||||
|
||||
# Resolve title or ID
|
||||
from hermes_cli.main import _resolve_session_by_name_or_id
|
||||
resolved = _resolve_session_by_name_or_id(target)
|
||||
target_id = resolved or target
|
||||
# Resolve numbered selection, title, or ID
|
||||
if target.isdigit():
|
||||
sessions = self._list_recent_sessions(limit=10)
|
||||
index = int(target)
|
||||
if index < 1 or index > len(sessions):
|
||||
_cprint(f" Resume index {index} is out of range.")
|
||||
_cprint(" Use /resume with no arguments to see available sessions.")
|
||||
return
|
||||
selected = sessions[index - 1]
|
||||
target_id = selected["id"]
|
||||
else:
|
||||
from hermes_cli.main import _resolve_session_by_name_or_id
|
||||
resolved = _resolve_session_by_name_or_id(target)
|
||||
target_id = resolved or target
|
||||
|
||||
session_meta = self._session_db.get_session(target_id)
|
||||
if not session_meta:
|
||||
|
|
|
|||
|
|
@ -12741,7 +12741,7 @@ class GatewayRunner:
|
|||
return t("gateway.title.current_no_title", session_id=session_id)
|
||||
|
||||
async def _handle_resume_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /resume command — switch to a previously-named session."""
|
||||
"""Handle /resume command — list or switch to a previous session."""
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
||||
|
|
@ -12750,30 +12750,44 @@ class GatewayRunner:
|
|||
session_key = self._session_key_for_source(source)
|
||||
name = event.get_command_args().strip()
|
||||
|
||||
def _list_titled_sessions() -> list[dict]:
|
||||
user_source = source.platform.value if source.platform else None
|
||||
sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)
|
||||
return [s for s in sessions if s.get("title")][:10]
|
||||
|
||||
if not name:
|
||||
# List recent titled sessions for this user/platform
|
||||
try:
|
||||
user_source = source.platform.value if source.platform else None
|
||||
sessions = self._session_db.list_sessions_rich(
|
||||
source=user_source, limit=10
|
||||
)
|
||||
titled = [s for s in sessions if s.get("title")]
|
||||
titled = _list_titled_sessions()
|
||||
if not titled:
|
||||
return t("gateway.resume.no_named_sessions")
|
||||
lines = [t("gateway.resume.list_header")]
|
||||
for s in titled[:10]:
|
||||
for idx, s in enumerate(titled[:10], start=1):
|
||||
title = s["title"]
|
||||
preview = s.get("preview", "")[:40]
|
||||
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
|
||||
lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part))
|
||||
lines.append(t("gateway.resume.list_footer"))
|
||||
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
|
||||
lines.append(t("gateway.resume.list_footer_numbered"))
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list titled sessions: %s", e)
|
||||
return t("gateway.resume.list_failed", error=e)
|
||||
|
||||
# Resolve the name to a session ID.
|
||||
target_id = self._session_db.resolve_session_by_title(name)
|
||||
# Resolve a numbered choice or a title to a session ID.
|
||||
if name.isdigit():
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
|
||||
return t("gateway.resume.list_failed", error=e)
|
||||
index = int(name)
|
||||
if index < 1 or index > len(titled):
|
||||
return t("gateway.resume.out_of_range", index=index)
|
||||
target = titled[index - 1]
|
||||
target_id = target.get("id")
|
||||
name = target.get("title") or name
|
||||
else:
|
||||
target_id = self._session_db.resolve_session_by_title(name)
|
||||
if not target_id:
|
||||
return t("gateway.resume.not_found", name=name)
|
||||
# Compression creates child continuations that hold the live transcript.
|
||||
|
|
|
|||
|
|
@ -237,9 +237,12 @@ gateway:
|
|||
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
|
||||
list_header: "📋 **Named Sessions**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
list_item_numbered: "{index}. **{title}**{preview_part}"
|
||||
list_preview_suffix: " — _{preview}_"
|
||||
list_footer: "\nUsage: `/resume <session name>`"
|
||||
list_footer_numbered: "\nUsage: `/resume <session name>` or `/resume <number>` (e.g. `/resume 1` for the most recent)"
|
||||
list_failed: "Could not list sessions: {error}"
|
||||
out_of_range: "Resume index {index} is out of range.\nUse `/resume` with no arguments to see available sessions."
|
||||
not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
|
||||
already_on: "📌 Already on session **{name}**."
|
||||
switch_failed: "Failed to switch session."
|
||||
|
|
|
|||
71
tests/cli/test_cli_resume_command.py
Normal file
71
tests/cli/test_cli_resume_command.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.session_id = "current_session"
|
||||
cli_obj._resumed = False
|
||||
cli_obj._pending_title = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = MagicMock()
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestCliResumeCommand:
|
||||
def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
|
||||
{"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
|
||||
])
|
||||
|
||||
shown = cli_obj._show_recent_sessions(reason="resume")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert shown is True
|
||||
assert "1" in output
|
||||
assert "2" in output
|
||||
assert "Coding" in output
|
||||
assert "Research" in output
|
||||
assert "/resume 2" in output
|
||||
assert "/resume <session title>" in output
|
||||
|
||||
def test_handle_resume_by_index_switches_to_numbered_session(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
])
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint") as mock_cprint,
|
||||
):
|
||||
cli_obj._handle_resume_command("/resume 2")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
assert "Resumed session sess_001" in printed
|
||||
assert "Research" in printed
|
||||
|
||||
def test_handle_resume_by_index_out_of_range(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
])
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj._handle_resume_command("/resume 9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "out of range" in printed.lower()
|
||||
assert "/resume" in printed
|
||||
assert cli_obj.session_id == "current_session"
|
||||
|
|
@ -88,6 +88,9 @@ class TestHandleResumeCommand:
|
|||
assert "Research" in result
|
||||
assert "Coding" in result
|
||||
assert "Named Sessions" in result
|
||||
assert "1." in result
|
||||
assert "2." in result
|
||||
assert "/resume 1" in result
|
||||
db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -104,6 +107,47 @@ class TestHandleResumeCommand:
|
|||
assert "/title" in result
|
||||
db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_by_index(self, tmp_path):
|
||||
"""Numeric argument resumes the indexed titled session from the list."""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session("sess_001", "telegram")
|
||||
db.create_session("sess_002", "telegram")
|
||||
db.set_session_title("sess_001", "Research")
|
||||
db.set_session_title("sess_002", "Coding")
|
||||
db.create_session("current_session_001", "telegram")
|
||||
|
||||
event = _make_event(text="/resume 2")
|
||||
runner = _make_runner(session_db=db, current_session_id="current_session_001",
|
||||
event=event)
|
||||
result = await runner._handle_resume_command(event)
|
||||
|
||||
assert "Resumed" in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
call_args = runner.session_store.switch_session.call_args
|
||||
assert call_args[0][1] == "sess_001"
|
||||
db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_index_out_of_range(self, tmp_path):
|
||||
"""Out-of-range numeric arguments show a helpful error."""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session("sess_001", "telegram")
|
||||
db.set_session_title("sess_001", "Research")
|
||||
db.create_session("current_session_001", "telegram")
|
||||
|
||||
event = _make_event(text="/resume 9")
|
||||
runner = _make_runner(session_db=db, current_session_id="current_session_001",
|
||||
event=event)
|
||||
result = await runner._handle_resume_command(event)
|
||||
|
||||
assert "out of range" in result.lower()
|
||||
assert "/resume" in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_by_name(self, tmp_path):
|
||||
"""Resolves a title and switches to that session."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue