Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5450,6 +5450,88 @@ def new_session(self, silent=False, title=None):
else:
print("(^_^)v New session started!")

def _handle_handoff_command(self, cmd_original: str) -> None:
"""Handle /handoff <platform> — hand off current session to a messaging platform."""
from hermes_state import format_session_db_unavailable

parts = cmd_original.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
_cprint(" Usage: /handoff <platform>")
_cprint(" Supported: telegram, discord, slack, whatsapp, signal, matrix")
_cprint(" The session will become available on that platform's home channel.")
return

platform = parts[1].strip().lower()
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
if platform not in supported:
_cprint(f" Unknown platform '{platform}'. Supported: {', '.join(sorted(supported))}")
return

# Ensure session is in the DB
if not self._session_db:
from hermes_state import SessionDB
self._session_db = SessionDB()

if not self._session_db:
_cprint(f" {format_session_db_unavailable()}")
return

# Make sure the session has a title
session_title = ""
try:
session_meta = self._session_db.get_session(self.session_id)
if session_meta:
session_title = session_meta.get("title") or ""
except Exception:
pass

if not session_title:
# Auto-title from conversation if not set
if hasattr(self, "agent") and self.agent and self.conversation_history:
last_user_msgs = [m for m in self.conversation_history[-6:] if m.get("role") == "user"]
if last_user_msgs:
title = last_user_msgs[0].get("content", "")[:60]
title = title.replace("\n", " ").strip()
if title:
session_title = title
self._session_db.set_session_title(self.session_id, title)

if not session_title:
session_title = "untitled session"

# Mark session for handoff
ok = self._session_db.set_handoff_pending(self.session_id, platform)
if not ok:
_cprint(f" Session is already pending handoff or not found.")
return

_cprint(f" Session '{session_title}' queued for handoff to {platform}.")
_cprint(f" The session will resume when the next message arrives on the {platform} home channel.")

# Also try to send a notification via send_message
try:
summary_lines = ["Handoff from CLI", f"Session: {session_title}"]
if hasattr(self, "agent") and self.agent:
last_msgs = self.conversation_history[-4:] if self.conversation_history else []
for msg in last_msgs:
role = msg.get("role", "")
content = str(msg.get("content", ""))[:120]
if content.strip():
summary_lines.append(f"[{role}] {content}")
summary = "\n".join(summary_lines)

from tools.send_message_tool import send_message_tool
result_json = send_message_tool({"target": platform, "message": summary})
import json
result = json.loads(result_json)
if result.get("success"):
_cprint(f" Notification sent to {platform} home channel.")
else:
err = result.get("error", "unknown error")
_cprint(f" Could not send notification to {platform}: {err}")
except Exception as e:
_cprint(f" Could not send notification: {e}")

def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
parts = cmd_original.split(None, 1)
Expand Down Expand Up @@ -6870,6 +6952,8 @@ def process_command(self, command: str) -> bool:
else:
from hermes_state import format_session_db_unavailable
_cprint(f" {format_session_db_unavailable()}")
elif canonical == "handoff":
self._handle_handoff_command(cmd_original)
elif canonical == "new":
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None
Expand Down
40 changes: 40 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6421,6 +6421,46 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g

# Build the context prompt to inject
context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii)

# Check for pending CLI handoff
if _is_new_session and self._session_db:
try:
platform_key = source.platform.value if source.platform else ""
handoff = self._session_db.find_pending_handoff(platform_key)
if handoff:
cli_session_id = handoff["id"]
cli_messages = self._session_db.get_messages(cli_session_id)
if cli_messages:
# Cap to last 200 messages to avoid context blowup
cli_messages = cli_messages[-200:]
transcript = []
for msg in cli_messages:
role = msg.get("role", "unknown")
content = str(msg.get("content") or "")
if content.strip():
label = {"user": "User", "assistant": "Assistant",
"system": "System", "tool": "Tool"}.get(role, role.title())
transcript.append(f"{label}: {content}")
if transcript:
handoff_title = handoff.get("title") or "untitled"
handoff_context = (
f"[Handoff from CLI session '{handoff_title}'. "
f"Continue the conversation below where it left off.]"
)
context_prompt = (
handoff_context
+ "\n\n--- Previous conversation ---\n"
+ "\n\n".join(transcript)
+ "\n--- End of previous conversation ---\n\n"
+ context_prompt
)
self._session_db.clear_handoff_pending(cli_session_id)
logger.info(
"Handoff: CLI session %s handed off to %s chat %s",
cli_session_id, platform_key, source.chat_id,
)
except Exception:
logger.debug("Handoff check failed", exc_info=True)

# If the previous session expired and was auto-reset, prepend a notice
# so the agent knows this is a fresh conversation (not an intentional /reset).
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ class CommandDef:
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
CommandDef("title", "Set a title for the current session", "Session",
args_hint="[name]"),
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
args_hint="<platform>", cli_only=True),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session",
Expand Down
45 changes: 45 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
handoff_pending INTEGER DEFAULT 0,
handoff_platform TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);

Expand Down Expand Up @@ -2836,3 +2838,46 @@ def maybe_auto_prune_and_vacuum(

return result

# ── Handoff (cross-platform session transfer) ──────────────────────────

def set_handoff_pending(self, session_id: str, platform: str) -> bool:
"""Mark a session as pending handoff to the given platform.

Returns True if the session was found and updated.
"""
def _do(conn):
cur = conn.execute(
"UPDATE sessions SET handoff_pending = 1, handoff_platform = ? "
"WHERE id = ? AND handoff_pending = 0",
(platform, session_id),
)
return cur.rowcount > 0
return self._execute_write(_do)

def find_pending_handoff(self, platform: str) -> Optional[Dict[str, Any]]:
"""Find the most recent session pending handoff for a platform.

Returns the session dict or None.
"""
try:
cur = self._conn.execute(
"SELECT * FROM sessions "
"WHERE handoff_pending = 1 AND handoff_platform = ? "
"ORDER BY started_at DESC LIMIT 1",
(platform,),
)
row = cur.fetchone()
return dict(row) if row else None
except Exception:
return None

def clear_handoff_pending(self, session_id: str) -> None:
"""Clear the handoff_pending flag on a session."""
def _do(conn):
conn.execute(
"UPDATE sessions SET handoff_pending = 0, handoff_platform = NULL "
"WHERE id = ?",
(session_id,),
)
self._execute_write(_do)

123 changes: 123 additions & 0 deletions tests/hermes_cli/test_session_handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Tests for session handoff (CLI to gateway platform)."""

from __future__ import annotations

import time
from unittest.mock import patch

import pytest

from hermes_state import SessionDB


class TestHandoffDB:
"""Test the handoff columns and helper methods on SessionDB."""

@pytest.fixture
def db(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
db = SessionDB(db_path=home / "state.db")
yield db

def _make_session(self, db, session_id, source="cli", title=None):
"""Insert a session row directly for testing."""
def _do(conn):
conn.execute(
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
"VALUES (?, ?, ?, ?)",
(session_id, source, title, time.time()),
)
db._execute_write(_do)

def test_handoff_columns_exist(self, db):
"""Verify handoff columns are in the sessions table after init."""
db._conn.execute("SELECT handoff_pending, handoff_platform FROM sessions LIMIT 0")

def test_set_handoff_pending(self, db):
"""Mark a session for handoff."""
session_id = "test-session-001"
self._make_session(db, session_id)
ok = db.set_handoff_pending(session_id, "telegram")
assert ok is True

session = db.get_session(session_id)
assert session["handoff_pending"] == 1
assert session["handoff_platform"] == "telegram"

def test_set_handoff_pending_no_double_mark(self, db):
"""Re-marking an already-pending session returns False."""
session_id = "test-session-002"
self._make_session(db, session_id)
ok1 = db.set_handoff_pending(session_id, "telegram")
assert ok1 is True
ok2 = db.set_handoff_pending(session_id, "discord")
assert ok2 is False # already pending

def test_find_pending_handoff(self, db):
"""Find a session pending handoff for a given platform."""
sid = "test-session-003"
self._make_session(db, sid)
db.set_handoff_pending(sid, "telegram")

handoff = db.find_pending_handoff("telegram")
assert handoff is not None
assert handoff["id"] == sid

# Should not find for other platforms
assert db.find_pending_handoff("discord") is None

def test_clear_handoff_pending(self, db):
"""Clear the handoff flag."""
sid = "test-session-004"
self._make_session(db, sid)
db.set_handoff_pending(sid, "telegram")
db.clear_handoff_pending(sid)

session = db.get_session(sid)
assert session["handoff_pending"] == 0

def test_full_handoff_flow(self, db):
"""End-to-end: mark → find → load messages → clear."""
sid = "test-session-005"
self._make_session(db, sid, title="my session")
db.append_message(sid, "user", "Hello")
db.append_message(sid, "assistant", "Hi there!")

# CLI side: mark for handoff
ok = db.set_handoff_pending(sid, "telegram")
assert ok is True

# Gateway side: find pending handoff
handoff = db.find_pending_handoff("telegram")
assert handoff is not None
assert handoff["id"] == sid
assert handoff["title"] == "my session"

# Load messages for context
messages = db.get_messages(sid)
assert len(messages) == 2
assert messages[0]["role"] == "user"
assert messages[1]["role"] == "assistant"

# Clear after injecting
db.clear_handoff_pending(sid)
assert db.find_pending_handoff("telegram") is None


class TestHandoffCommand:
"""Test the CLI /handoff command handler."""

def test_command_registered(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("handoff")
assert cmd is not None
assert cmd.name == "handoff"
assert cmd.category == "Session"

def test_invalid_platform(self):
"""Test that unknown platforms are rejected."""
supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"}
assert "telegram" in supported
assert "foo" not in supported
Loading