feat: support numbered resume selection in cli and gateway
This commit is contained in:
parent
4f4e337c47
commit
fef733d56b
31
cli.py
31
cli.py
@ -6172,15 +6172,16 @@ class HermesCLI:
|
|||||||
else:
|
else:
|
||||||
print(" Recent sessions:")
|
print(" Recent sessions:")
|
||||||
print()
|
print()
|
||||||
print(f" {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||||
print(f" {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
|
print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
|
||||||
for session in sessions:
|
for idx, session in enumerate(sessions, start=1):
|
||||||
title = session.get("title") or "—"
|
title = session.get("title") or "—"
|
||||||
preview = (session.get("preview") or "")[:38]
|
preview = (session.get("preview") or "")[:38]
|
||||||
last_active = _relative_time(session.get("last_active"))
|
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()
|
||||||
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()
|
print()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -6525,7 +6526,7 @@ class HermesCLI:
|
|||||||
target = parts[1].strip() if len(parts) > 1 else ""
|
target = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
if not target:
|
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"):
|
if self._show_recent_sessions(reason="resume"):
|
||||||
return
|
return
|
||||||
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
_cprint(" Tip: Use /history or `hermes sessions list` to find sessions.")
|
||||||
@ -6536,10 +6537,20 @@ class HermesCLI:
|
|||||||
_cprint(f" {format_session_db_unavailable()}")
|
_cprint(f" {format_session_db_unavailable()}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Resolve title or ID
|
# Resolve numbered selection, title, or ID
|
||||||
from hermes_cli.main import _resolve_session_by_name_or_id
|
if target.isdigit():
|
||||||
resolved = _resolve_session_by_name_or_id(target)
|
sessions = self._list_recent_sessions(limit=10)
|
||||||
target_id = resolved or target
|
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)
|
session_meta = self._session_db.get_session(target_id)
|
||||||
if not session_meta:
|
if not session_meta:
|
||||||
|
|||||||
@ -12741,7 +12741,7 @@ class GatewayRunner:
|
|||||||
return t("gateway.title.current_no_title", session_id=session_id)
|
return t("gateway.title.current_no_title", session_id=session_id)
|
||||||
|
|
||||||
async def _handle_resume_command(self, event: MessageEvent) -> str:
|
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:
|
if not self._session_db:
|
||||||
from hermes_state import format_session_db_unavailable
|
from hermes_state import format_session_db_unavailable
|
||||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
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)
|
session_key = self._session_key_for_source(source)
|
||||||
name = event.get_command_args().strip()
|
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:
|
if not name:
|
||||||
# List recent titled sessions for this user/platform
|
# List recent titled sessions for this user/platform
|
||||||
try:
|
try:
|
||||||
user_source = source.platform.value if source.platform else None
|
titled = _list_titled_sessions()
|
||||||
sessions = self._session_db.list_sessions_rich(
|
|
||||||
source=user_source, limit=10
|
|
||||||
)
|
|
||||||
titled = [s for s in sessions if s.get("title")]
|
|
||||||
if not titled:
|
if not titled:
|
||||||
return t("gateway.resume.no_named_sessions")
|
return t("gateway.resume.no_named_sessions")
|
||||||
lines = [t("gateway.resume.list_header")]
|
lines = [t("gateway.resume.list_header")]
|
||||||
for s in titled[:10]:
|
for idx, s in enumerate(titled[:10], start=1):
|
||||||
title = s["title"]
|
title = s["title"]
|
||||||
preview = s.get("preview", "")[:40]
|
preview = s.get("preview", "")[:40]
|
||||||
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
|
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_item_numbered", index=idx, title=title, preview_part=preview_part))
|
||||||
lines.append(t("gateway.resume.list_footer"))
|
lines.append(t("gateway.resume.list_footer_numbered"))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to list titled sessions: %s", e)
|
logger.debug("Failed to list titled sessions: %s", e)
|
||||||
return t("gateway.resume.list_failed", error=e)
|
return t("gateway.resume.list_failed", error=e)
|
||||||
|
|
||||||
# Resolve the name to a session ID.
|
# Resolve a numbered choice or a title to a session ID.
|
||||||
target_id = self._session_db.resolve_session_by_title(name)
|
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:
|
if not target_id:
|
||||||
return t("gateway.resume.not_found", name=name)
|
return t("gateway.resume.not_found", name=name)
|
||||||
# Compression creates child continuations that hold the live transcript.
|
# 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."
|
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_header: "📋 **Named Sessions**\n"
|
||||||
list_item: "• **{title}**{preview_part}"
|
list_item: "• **{title}**{preview_part}"
|
||||||
|
list_item_numbered: "{index}. **{title}**{preview_part}"
|
||||||
list_preview_suffix: " — _{preview}_"
|
list_preview_suffix: " — _{preview}_"
|
||||||
list_footer: "\nUsage: `/resume <session name>`"
|
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}"
|
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."
|
not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
|
||||||
already_on: "📌 Already on session **{name}**."
|
already_on: "📌 Already on session **{name}**."
|
||||||
switch_failed: "Failed to switch session."
|
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 "Research" in result
|
||||||
assert "Coding" in result
|
assert "Coding" in result
|
||||||
assert "Named Sessions" in result
|
assert "Named Sessions" in result
|
||||||
|
assert "1." in result
|
||||||
|
assert "2." in result
|
||||||
|
assert "/resume 1" in result
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -104,6 +107,47 @@ class TestHandleResumeCommand:
|
|||||||
assert "/title" in result
|
assert "/title" in result
|
||||||
db.close()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_resume_by_name(self, tmp_path):
|
async def test_resume_by_name(self, tmp_path):
|
||||||
"""Resolves a title and switches to that session."""
|
"""Resolves a title and switches to that session."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user