mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Merge feature/background-command: add /background slash command
Adds /background <prompt> command to both CLI and gateway platforms. Spawns a new agent session in the background — users can keep chatting while the task runs, and results are delivered when done. CLI: threaded execution with rich Panel output Gateway: asyncio task with platform adapter delivery (text, images, media) Includes 15 new tests and updates to command registry.
This commit is contained in:
113
cli.py
113
cli.py
@@ -1244,6 +1244,10 @@ class HermesCLI:
|
||||
self._command_running = False
|
||||
self._command_status = ""
|
||||
|
||||
# Background task tracking: {task_id: threading.Thread}
|
||||
self._background_tasks: Dict[str, threading.Thread] = {}
|
||||
self._background_task_counter = 0
|
||||
|
||||
def _invalidate(self, min_interval: float = 0.25) -> None:
|
||||
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
|
||||
import time as _time
|
||||
@@ -2825,6 +2829,8 @@ class HermesCLI:
|
||||
self._reload_mcp()
|
||||
elif cmd_lower.startswith("/rollback"):
|
||||
self._handle_rollback_command(cmd_original)
|
||||
elif cmd_lower.startswith("/background"):
|
||||
self._handle_background_command(cmd_original)
|
||||
elif cmd_lower.startswith("/skin"):
|
||||
self._handle_skin_command(cmd_original)
|
||||
else:
|
||||
@@ -2869,6 +2875,113 @@ class HermesCLI:
|
||||
|
||||
return True
|
||||
|
||||
def _handle_background_command(self, cmd: str):
|
||||
"""Handle /background <prompt> — run a prompt in a separate background session.
|
||||
|
||||
Spawns a new AIAgent in a background thread with its own session.
|
||||
When it completes, prints the result to the CLI without modifying
|
||||
the active session's conversation history.
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
_cprint(" Usage: /background <prompt>")
|
||||
_cprint(" Example: /background Summarize the top HN stories today")
|
||||
_cprint(" The task runs in a separate session and results display here when done.")
|
||||
return
|
||||
|
||||
prompt = parts[1].strip()
|
||||
self._background_task_counter += 1
|
||||
task_num = self._background_task_counter
|
||||
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
# Make sure we have valid credentials
|
||||
if not self._ensure_runtime_credentials():
|
||||
_cprint(" (>_<) Cannot start background task: no valid credentials.")
|
||||
return
|
||||
|
||||
_cprint(f" 🔄 Background task #{task_num} started: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
||||
_cprint(f" Task ID: {task_id}")
|
||||
_cprint(f" You can continue chatting — results will appear when done.\n")
|
||||
|
||||
def run_background():
|
||||
try:
|
||||
bg_agent = AIAgent(
|
||||
model=self.model,
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url,
|
||||
provider=self.provider,
|
||||
api_mode=self.api_mode,
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
session_id=task_id,
|
||||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
reasoning_config=self.reasoning_config,
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
fallback_model=self._fallback_model,
|
||||
)
|
||||
|
||||
result = bg_agent.run_conversation(
|
||||
user_message=prompt,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
response = result.get("final_response", "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
|
||||
# Display result in the CLI (thread-safe via patch_stdout)
|
||||
print()
|
||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
||||
_cprint(f" ✅ Background task #{task_num} complete")
|
||||
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
||||
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
|
||||
if response:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
label = _skin.get_branding("response_label", "⚕ Hermes")
|
||||
_resp_color = _skin.get_color("response_border", "#CD7F32")
|
||||
except Exception:
|
||||
label = "⚕ Hermes"
|
||||
_resp_color = "#CD7F32"
|
||||
|
||||
_chat_console = ChatConsole()
|
||||
_chat_console.print(Panel(
|
||||
response,
|
||||
title=f"[bold]{label} (background #{task_num})[/bold]",
|
||||
title_align="left",
|
||||
border_style=_resp_color,
|
||||
box=rich_box.HORIZONTALS,
|
||||
padding=(1, 2),
|
||||
))
|
||||
else:
|
||||
_cprint(" (No response generated)")
|
||||
|
||||
# Play bell if enabled
|
||||
if self.bell_on_complete:
|
||||
sys.stdout.write("\a")
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
_cprint(f" ❌ Background task #{task_num} failed: {e}")
|
||||
finally:
|
||||
self._background_tasks.pop(task_id, None)
|
||||
if self._app:
|
||||
self._invalidate(min_interval=0)
|
||||
|
||||
thread = threading.Thread(target=run_background, daemon=True, name=f"bg-task-{task_id}")
|
||||
self._background_tasks[task_id] = thread
|
||||
thread.start()
|
||||
|
||||
def _handle_skin_command(self, cmd: str):
|
||||
"""Handle /skin [name] — show or change the display skin."""
|
||||
try:
|
||||
|
||||
209
gateway/run.py
209
gateway/run.py
@@ -806,7 +806,8 @@ class GatewayRunner:
|
||||
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
||||
"personality", "retry", "undo", "sethome", "set-home",
|
||||
"compress", "usage", "insights", "reload-mcp", "reload_mcp",
|
||||
"update", "title", "resume", "provider", "rollback"}
|
||||
"update", "title", "resume", "provider", "rollback",
|
||||
"background"}
|
||||
if command and command in _known_commands:
|
||||
await self.hooks.emit(f"command:{command}", {
|
||||
"platform": source.platform.value if source.platform else "",
|
||||
@@ -868,6 +869,9 @@ class GatewayRunner:
|
||||
|
||||
if command == "rollback":
|
||||
return await self._handle_rollback_command(event)
|
||||
|
||||
if command == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
# User-defined quick commands (bypass agent loop, no LLM call)
|
||||
if command:
|
||||
@@ -1495,6 +1499,7 @@ class GatewayRunner:
|
||||
"`/usage` — Show token usage for this session",
|
||||
"`/insights [days]` — Show usage insights and analytics",
|
||||
"`/rollback [number]` — List or restore filesystem checkpoints",
|
||||
"`/background <prompt>` — Run a prompt in a separate background session",
|
||||
"`/reload-mcp` — Reload MCP servers from config",
|
||||
"`/update` — Update Hermes Agent to the latest version",
|
||||
"`/help` — Show this message",
|
||||
@@ -1904,6 +1909,208 @@ class GatewayRunner:
|
||||
)
|
||||
return f"❌ {result['error']}"
|
||||
|
||||
async def _handle_background_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /background <prompt> — run a prompt in a separate background session.
|
||||
|
||||
Spawns a new AIAgent in a background thread with its own session.
|
||||
When it completes, sends the result back to the same chat without
|
||||
modifying the active session's conversation history.
|
||||
"""
|
||||
prompt = event.get_command_args().strip()
|
||||
if not prompt:
|
||||
return (
|
||||
"Usage: /background <prompt>\n"
|
||||
"Example: /background Summarize the top HN stories today\n\n"
|
||||
"Runs the prompt in a separate session. "
|
||||
"You can keep chatting — the result will appear here when done."
|
||||
)
|
||||
|
||||
source = event.source
|
||||
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}"
|
||||
|
||||
# Fire-and-forget the background task
|
||||
asyncio.create_task(
|
||||
self._run_background_task(prompt, source, task_id)
|
||||
)
|
||||
|
||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
return f'🔄 Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done.'
|
||||
|
||||
async def _run_background_task(
|
||||
self, prompt: str, source: "SessionSource", task_id: str
|
||||
) -> None:
|
||||
"""Execute a background agent task and deliver the result to the chat."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter:
|
||||
logger.warning("No adapter for platform %s in background task %s", source.platform, task_id)
|
||||
return
|
||||
|
||||
_thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
|
||||
try:
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
if not runtime_kwargs.get("api_key"):
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
f"❌ Background task {task_id} failed: no provider credentials configured.",
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
return
|
||||
|
||||
# Read model from config (same as _run_agent)
|
||||
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
|
||||
try:
|
||||
import yaml as _y
|
||||
_cfg_path = _hermes_home / "config.yaml"
|
||||
if _cfg_path.exists():
|
||||
with open(_cfg_path, encoding="utf-8") as _f:
|
||||
_cfg = _y.safe_load(_f) or {}
|
||||
_model_cfg = _cfg.get("model", {})
|
||||
if isinstance(_model_cfg, str):
|
||||
model = _model_cfg
|
||||
elif isinstance(_model_cfg, dict):
|
||||
model = _model_cfg.get("default", model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine toolset (same logic as _run_agent)
|
||||
default_toolset_map = {
|
||||
Platform.LOCAL: "hermes-cli",
|
||||
Platform.TELEGRAM: "hermes-telegram",
|
||||
Platform.DISCORD: "hermes-discord",
|
||||
Platform.WHATSAPP: "hermes-whatsapp",
|
||||
Platform.SLACK: "hermes-slack",
|
||||
Platform.SIGNAL: "hermes-signal",
|
||||
Platform.HOMEASSISTANT: "hermes-homeassistant",
|
||||
}
|
||||
platform_toolsets_config = {}
|
||||
try:
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path, 'r', encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
platform_toolsets_config = user_config.get("platform_toolsets", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
platform_config_key = {
|
||||
Platform.LOCAL: "cli",
|
||||
Platform.TELEGRAM: "telegram",
|
||||
Platform.DISCORD: "discord",
|
||||
Platform.WHATSAPP: "whatsapp",
|
||||
Platform.SLACK: "slack",
|
||||
Platform.SIGNAL: "signal",
|
||||
Platform.HOMEASSISTANT: "homeassistant",
|
||||
}.get(source.platform, "telegram")
|
||||
|
||||
config_toolsets = platform_toolsets_config.get(platform_config_key)
|
||||
if config_toolsets and isinstance(config_toolsets, list):
|
||||
enabled_toolsets = config_toolsets
|
||||
else:
|
||||
default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
|
||||
enabled_toolsets = [default_toolset]
|
||||
|
||||
platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
|
||||
|
||||
pr = self._provider_routing
|
||||
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
|
||||
|
||||
def run_sync():
|
||||
agent = AIAgent(
|
||||
model=model,
|
||||
**runtime_kwargs,
|
||||
max_iterations=max_iterations,
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
reasoning_config=self._reasoning_config,
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
provider_sort=pr.get("sort"),
|
||||
provider_require_parameters=pr.get("require_parameters", False),
|
||||
provider_data_collection=pr.get("data_collection"),
|
||||
session_id=task_id,
|
||||
platform=platform_key,
|
||||
session_db=self._session_db,
|
||||
fallback_model=self._fallback_model,
|
||||
)
|
||||
|
||||
return agent.run_conversation(
|
||||
user_message=prompt,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, run_sync)
|
||||
|
||||
response = result.get("final_response", "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
|
||||
# Extract media files from the response
|
||||
if response:
|
||||
media_files, response = adapter.extract_media(response)
|
||||
images, text_content = adapter.extract_images(response)
|
||||
|
||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
header = f'✅ Background task complete\nPrompt: "{preview}"\n\n'
|
||||
|
||||
if text_content:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=header + text_content,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif not images and not media_files:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=header + "(No response generated)",
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
|
||||
# Send extracted images
|
||||
for image_url, alt_text in (images or []):
|
||||
try:
|
||||
await adapter.send_image(
|
||||
chat_id=source.chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send media files
|
||||
for media_path in (media_files or []):
|
||||
try:
|
||||
await adapter.send_file(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=f'✅ Background task complete\nPrompt: "{preview}"\n\n(No response generated)',
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Background task %s failed", task_id)
|
||||
try:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=f"❌ Background task {task_id} failed: {e}",
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _handle_compress_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /compress command -- manually compress conversation context."""
|
||||
source = event.source
|
||||
|
||||
@@ -26,6 +26,7 @@ COMMANDS_BY_CATEGORY = {
|
||||
"/title": "Set a title for the current session (usage: /title My Session Name)",
|
||||
"/compress": "Manually compress conversation context (flush memories + summarize)",
|
||||
"/rollback": "List or restore filesystem checkpoints (usage: /rollback [number])",
|
||||
"/background": "Run a prompt in the background (usage: /background <prompt>)",
|
||||
},
|
||||
"Configuration": {
|
||||
"/config": "Show current configuration",
|
||||
|
||||
305
tests/gateway/test_background_command.py
Normal file
305
tests/gateway/test_background_command.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Tests for /background gateway slash command.
|
||||
|
||||
Tests the _handle_background_command handler (run a prompt in a separate
|
||||
background session) across gateway messenger platforms.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/background", platform=Platform.TELEGRAM,
|
||||
user_id="12345", chat_id="67890"):
|
||||
"""Build a MessageEvent for testing."""
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a bare GatewayRunner with minimal mocks."""
|
||||
from gateway.run import GatewayRunner
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._session_db = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._running_agents = {}
|
||||
|
||||
mock_store = MagicMock()
|
||||
runner.session_store = mock_store
|
||||
|
||||
from gateway.hooks import HookRegistry
|
||||
runner.hooks = HookRegistry()
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_background_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleBackgroundCommand:
|
||||
"""Tests for GatewayRunner._handle_background_command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_prompt_shows_usage(self):
|
||||
"""Running /background with no prompt shows usage."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(text="/background")
|
||||
result = await runner._handle_background_command(event)
|
||||
assert "Usage:" in result
|
||||
assert "/background" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_prompt_shows_usage(self):
|
||||
"""Running /background with only whitespace shows usage."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(text="/background ")
|
||||
result = await runner._handle_background_command(event)
|
||||
assert "Usage:" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_prompt_starts_task(self):
|
||||
"""Running /background with a prompt returns confirmation and starts task."""
|
||||
runner = _make_runner()
|
||||
|
||||
# Patch asyncio.create_task to capture the coroutine
|
||||
created_tasks = []
|
||||
original_create_task = asyncio.create_task
|
||||
|
||||
def capture_task(coro, *args, **kwargs):
|
||||
# Close the coroutine to avoid warnings
|
||||
coro.close()
|
||||
mock_task = MagicMock()
|
||||
created_tasks.append(mock_task)
|
||||
return mock_task
|
||||
|
||||
with patch("gateway.run.asyncio.create_task", side_effect=capture_task):
|
||||
event = _make_event(text="/background Summarize the top HN stories")
|
||||
result = await runner._handle_background_command(event)
|
||||
|
||||
assert "🔄" in result
|
||||
assert "Background task started" in result
|
||||
assert "bg_" in result # task ID starts with bg_
|
||||
assert "Summarize the top HN stories" in result
|
||||
assert len(created_tasks) == 1 # background task was created
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_truncated_in_preview(self):
|
||||
"""Long prompts are truncated to 60 chars in the confirmation message."""
|
||||
runner = _make_runner()
|
||||
long_prompt = "A" * 100
|
||||
|
||||
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
|
||||
event = _make_event(text=f"/background {long_prompt}")
|
||||
result = await runner._handle_background_command(event)
|
||||
|
||||
assert "..." in result
|
||||
# Should not contain the full prompt
|
||||
assert long_prompt not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_id_is_unique(self):
|
||||
"""Each background task gets a unique task ID."""
|
||||
runner = _make_runner()
|
||||
task_ids = set()
|
||||
|
||||
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
|
||||
for i in range(5):
|
||||
event = _make_event(text=f"/background task {i}")
|
||||
result = await runner._handle_background_command(event)
|
||||
# Extract task ID from result (format: "Task ID: bg_HHMMSS_hex")
|
||||
for line in result.split("\n"):
|
||||
if "Task ID:" in line:
|
||||
tid = line.split("Task ID:")[1].strip()
|
||||
task_ids.add(tid)
|
||||
|
||||
assert len(task_ids) == 5 # all unique
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_works_across_platforms(self):
|
||||
"""The /background command works for all platforms."""
|
||||
for platform in [Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK]:
|
||||
runner = _make_runner()
|
||||
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
|
||||
event = _make_event(
|
||||
text="/background test task",
|
||||
platform=platform,
|
||||
)
|
||||
result = await runner._handle_background_command(event)
|
||||
assert "Background task started" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_background_task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunBackgroundTask:
|
||||
"""Tests for GatewayRunner._run_background_task (the actual execution)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_adapter_returns_silently(self):
|
||||
"""When no adapter is available, the task returns without error."""
|
||||
runner = _make_runner()
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
# No adapters set — should not raise
|
||||
await runner._run_background_task("test prompt", source, "bg_test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_credentials_sends_error(self):
|
||||
"""When provider credentials are missing, an error is sent."""
|
||||
runner = _make_runner()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
runner.adapters[Platform.TELEGRAM] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": None}):
|
||||
await runner._run_background_task("test prompt", source, "bg_test")
|
||||
|
||||
# Should have sent an error message
|
||||
mock_adapter.send.assert_called_once()
|
||||
call_args = mock_adapter.send.call_args
|
||||
assert "failed" in call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "").lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_task_sends_result(self):
|
||||
"""When the agent completes successfully, the result is sent."""
|
||||
runner = _make_runner()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
mock_adapter.extract_media = MagicMock(return_value=([], "Hello from background!"))
|
||||
mock_adapter.extract_images = MagicMock(return_value=([], "Hello from background!"))
|
||||
runner.adapters[Platform.TELEGRAM] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
mock_result = {"final_response": "Hello from background!", "messages": []}
|
||||
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
|
||||
patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_agent_instance = MagicMock()
|
||||
mock_agent_instance.run_conversation.return_value = mock_result
|
||||
MockAgent.return_value = mock_agent_instance
|
||||
|
||||
await runner._run_background_task("say hello", source, "bg_test")
|
||||
|
||||
# Should have sent the result
|
||||
mock_adapter.send.assert_called_once()
|
||||
call_args = mock_adapter.send.call_args
|
||||
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
|
||||
assert "Background task complete" in content
|
||||
assert "Hello from background!" in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_sends_error_message(self):
|
||||
"""When the agent raises an exception, an error message is sent."""
|
||||
runner = _make_runner()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
runner.adapters[Platform.TELEGRAM] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
with patch("gateway.run._resolve_runtime_agent_kwargs", side_effect=RuntimeError("boom")):
|
||||
await runner._run_background_task("test prompt", source, "bg_test")
|
||||
|
||||
mock_adapter.send.assert_called_once()
|
||||
call_args = mock_adapter.send.call_args
|
||||
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
|
||||
assert "failed" in content.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /background in help and known_commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackgroundInHelp:
|
||||
"""Verify /background appears in help text and known commands."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_in_help_output(self):
|
||||
"""The /help output includes /background."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(text="/help")
|
||||
result = await runner._handle_help_command(event)
|
||||
assert "/background" in result
|
||||
|
||||
def test_background_is_known_command(self):
|
||||
"""The /background command is in the _known_commands set."""
|
||||
from gateway.run import GatewayRunner
|
||||
import inspect
|
||||
source = inspect.getsource(GatewayRunner._handle_message)
|
||||
assert '"background"' in source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI /background command definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackgroundInCLICommands:
|
||||
"""Verify /background is registered in the CLI command system."""
|
||||
|
||||
def test_background_in_commands_dict(self):
|
||||
"""The /background command is in the COMMANDS dict."""
|
||||
from hermes_cli.commands import COMMANDS
|
||||
assert "/background" in COMMANDS
|
||||
|
||||
def test_background_in_session_category(self):
|
||||
"""The /background command is in the Session category."""
|
||||
from hermes_cli.commands import COMMANDS_BY_CATEGORY
|
||||
assert "/background" in COMMANDS_BY_CATEGORY["Session"]
|
||||
|
||||
def test_background_autocompletes(self):
|
||||
"""The /background command appears in autocomplete results."""
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
completer = SlashCommandCompleter()
|
||||
doc = Document("backgro") # Partial match
|
||||
completions = list(completer.get_completions(doc, None))
|
||||
# Text doesn't start with / so no completions
|
||||
assert len(completions) == 0
|
||||
|
||||
doc = Document("/backgro") # With slash prefix
|
||||
completions = list(completer.get_completions(doc, None))
|
||||
cmd_displays = [str(c.display) for c in completions]
|
||||
assert any("/background" in d for d in cmd_displays)
|
||||
@@ -12,7 +12,7 @@ EXPECTED_COMMANDS = {
|
||||
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||
"/reload-mcp", "/rollback", "/skin", "/quit",
|
||||
"/reload-mcp", "/rollback", "/background", "/skin", "/quit",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user