1939 lines
68 KiB
Python
1939 lines
68 KiB
Python
"""Tests for topic-aware gateway progress updates."""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import sys
|
|
import time
|
|
import types
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import gateway.platforms.base as base_platform
|
|
from gateway.config import Platform, PlatformConfig, StreamingConfig
|
|
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class ProgressCaptureAdapter(BasePlatformAdapter):
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(PlatformConfig(enabled=True, token="***"), platform)
|
|
self.sent = []
|
|
self.edits = []
|
|
self.typing = []
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
return None
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
self.sent.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"content": content,
|
|
"reply_to": reply_to,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id="progress-1")
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
async def send_typing(self, chat_id, metadata=None) -> None:
|
|
self.typing.append({"chat_id": chat_id, "metadata": metadata})
|
|
|
|
async def stop_typing(self, chat_id) -> None:
|
|
self.typing.append({"chat_id": chat_id, "metadata": {"stopped": True}})
|
|
|
|
async def get_chat_info(self, chat_id: str):
|
|
return {"id": chat_id}
|
|
|
|
|
|
class DiscordProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
"""Capture sends while exercising Discord's real preview formatter."""
|
|
|
|
def __init__(self):
|
|
super().__init__(platform=Platform.DISCORD)
|
|
|
|
def format_tool_preview(self, preview, **kwargs):
|
|
from plugins.platforms.discord.adapter import DiscordAdapter
|
|
|
|
return DiscordAdapter.format_tool_preview(self, preview, **kwargs)
|
|
|
|
|
|
class MediaCaptureProgressAdapter(ProgressCaptureAdapter):
|
|
"""Capture native image batches without contacting a platform API."""
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self.image_batches = []
|
|
|
|
async def send_multiple_images(
|
|
self, chat_id, images, metadata=None, human_delay=0.0
|
|
) -> None:
|
|
self.image_batches.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"images": images,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
|
|
|
|
class SmallLimitProgressAdapter(ProgressCaptureAdapter):
|
|
"""Adapter with a tiny platform limit to exercise progress rollover."""
|
|
|
|
MAX_MESSAGE_LENGTH = 180
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self._next_id = 0
|
|
self.oversized_edits = []
|
|
self.oversized_sends = []
|
|
|
|
def _mint_id(self):
|
|
self._next_id += 1
|
|
return f"progress-{self._next_id}"
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
if len(content) > self.MAX_MESSAGE_LENGTH:
|
|
self.oversized_sends.append(content)
|
|
self.sent.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"content": content,
|
|
"reply_to": reply_to,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=self._mint_id())
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
if len(content) > self.MAX_MESSAGE_LENGTH:
|
|
self.oversized_edits.append(content)
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class MetadataEditProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize: bool = False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class RetryableFirstEditProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
"""Fail one progress edit transiently, then accept later edits."""
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self.edit_outcomes = []
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
if not self.edit_outcomes:
|
|
self.edit_outcomes.append(False)
|
|
return SendResult(
|
|
success=False,
|
|
error="temporary network failure",
|
|
retryable=True,
|
|
error_kind="transient",
|
|
)
|
|
self.edit_outcomes.append(True)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class RetryableOverflowEditProgressAdapter(SmallLimitProgressAdapter):
|
|
"""Fail the first split edit transiently, then keep editing."""
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self.retryable_edit_failures = 0
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
if self.retryable_edit_failures == 0:
|
|
self.retryable_edit_failures += 1
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(
|
|
success=False,
|
|
error="temporary network failure",
|
|
retryable=True,
|
|
error_kind="transient",
|
|
)
|
|
return await super().edit_message(chat_id, message_id, content)
|
|
|
|
|
|
class NonEditingProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
SUPPORTS_MESSAGE_EDITING = False
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
raise AssertionError("non-editable adapters should not receive edit_message calls")
|
|
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
# Capture anything passed via kwargs (older code path) but don't
|
|
# freeze it — production now assigns tool_progress_callback after
|
|
# construction (see gateway/run.py around the agent-cache hit),
|
|
# so we must read it at call time, not at init.
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
if cb is not None:
|
|
cb("tool.started", "terminal", "pwd", {})
|
|
time.sleep(0.35)
|
|
cb("tool.started", "browser_navigate", "https://example.com", {})
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class NativeTaskCardAdapter(ProgressCaptureAdapter):
|
|
def __init__(self, platform=Platform.SLACK):
|
|
super().__init__(platform=platform)
|
|
self.native_updates = []
|
|
self.native_stops = 0
|
|
|
|
def native_task_cards_enabled(self):
|
|
return True
|
|
|
|
async def send_native_task_card_progress(
|
|
self,
|
|
chat_id,
|
|
tasks,
|
|
*,
|
|
title,
|
|
reply_to=None,
|
|
metadata=None,
|
|
fallback_text=None,
|
|
) -> SendResult:
|
|
self.native_updates.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"tasks": [dict(task) for task in tasks],
|
|
"metadata": dict(metadata or {}),
|
|
"fallback_text": fallback_text,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id="native-stream-1")
|
|
|
|
async def stop_native_task_card_progress(
|
|
self, chat_id, *, reply_to=None, metadata=None
|
|
):
|
|
self.native_stops += 1
|
|
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize=False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class FailingNativeTaskCardAdapter(NativeTaskCardAdapter):
|
|
async def send_native_task_card_progress(self, *args, **kwargs) -> SendResult:
|
|
await super().send_native_task_card_progress(*args, **kwargs)
|
|
return SendResult(success=False, error="native stream unavailable", retryable=True)
|
|
|
|
|
|
class DuplicateNativeToolsAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tool_start_callback = kwargs.get("tool_start_callback")
|
|
self.tool_complete_callback = kwargs.get("tool_complete_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_start_callback("call-a", "web_search", {"query": "alpha"})
|
|
time.sleep(0.15)
|
|
self.tool_start_callback("call-b", "web_search", {"query": "beta"})
|
|
time.sleep(0.15)
|
|
# Complete the second same-name call first. Correlation by tool name
|
|
# would incorrectly mark call-a as failed here.
|
|
self.tool_complete_callback(
|
|
"call-b", "web_search", {"query": "beta"}, '{"error": "boom"}'
|
|
)
|
|
time.sleep(0.15)
|
|
self.tool_complete_callback(
|
|
"call-a", "web_search", {"query": "alpha"}, '{"success": true}'
|
|
)
|
|
time.sleep(0.15)
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
class ThinkingAgent:
|
|
"""Agent that emits _thinking scratch text (no tool calls).
|
|
|
|
Used to prove the progress callback relays _thinking bubbles when
|
|
thinking_progress is enabled but tool_progress is off.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
if cb is not None:
|
|
cb("_thinking", "weighing the options here")
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class LongPreviewAgent:
|
|
"""Agent that emits a tool call with a very long preview string."""
|
|
LONG_CMD = "cd /home/teknium/.hermes/hermes-agent/.worktrees/hermes-d8860339 && source .venv/bin/activate && python -m pytest tests/gateway/test_run_progress_topics.py -n0 -q"
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback("tool.started", "terminal", self.LONG_CMD, {})
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class UrlPreviewAgent:
|
|
URL = "https://hermes-agent.nousresearch.com/docs/gateway/discord/tool-progress"
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started",
|
|
"web_extract",
|
|
self.URL,
|
|
{"urls": [self.URL]},
|
|
)
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class DelayedProgressAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback("tool.started", "terminal", "first command", {})
|
|
time.sleep(0.45)
|
|
self.tool_progress_callback("tool.started", "terminal", "second command", {})
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class RetryableEditProgressAgent:
|
|
"""Keep the turn alive long enough to retry the same progress bubble."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
callback = self.tool_progress_callback
|
|
assert callback is not None
|
|
callback("tool.started", "terminal", "first command", {})
|
|
time.sleep(0.5)
|
|
callback("tool.started", "terminal", "second command", {})
|
|
time.sleep(1.7)
|
|
callback("tool.started", "terminal", "third command", {})
|
|
time.sleep(0.5)
|
|
callback("tool.started", "terminal", "fourth command", {})
|
|
time.sleep(0.6)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class ManyProgressLinesAgent:
|
|
"""Emits enough tool-progress lines to exceed a single platform bubble."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
assert cb is not None
|
|
cb("tool.started", "terminal", "first-short", {})
|
|
# Let the progress task create the first editable bubble, then enqueue
|
|
# the rest quickly. The cancellation drain must roll them into fresh
|
|
# editable bubbles instead of trying to edit the first one past limit.
|
|
time.sleep(0.35)
|
|
for idx in range(1, 8):
|
|
cb("tool.started", "terminal", f"overflow-line-{idx}-" + "x" * 45, {})
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class DelayedInterimAgent:
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.interim_assistant_callback("first interim")
|
|
time.sleep(0.45)
|
|
self.interim_assistant_callback("second interim")
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
def _make_runner(adapter):
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
GatewayRunner = gateway_run.GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {adapter.platform: adapter}
|
|
runner._voice_mode = {}
|
|
runner._prefill_messages = []
|
|
runner._ephemeral_system_prompt = ""
|
|
runner._reasoning_config = None
|
|
runner._provider_routing = {}
|
|
runner._fallback_model = None
|
|
runner._session_db = None
|
|
runner._running_agents = {}
|
|
runner._session_run_generation = {}
|
|
runner.session_store = SimpleNamespace(_entries={}, _save=lambda: None)
|
|
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
|
runner.config = SimpleNamespace(
|
|
thread_sessions_per_user=False,
|
|
group_sessions_per_user=False,
|
|
stt_enabled=False,
|
|
)
|
|
return runner
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch, tmp_path):
|
|
"""Slack DM progress should keep event ts fallback threading."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
# Since PR #8006, Slack's built-in display tier sets tool_progress="off"
|
|
# by default. Override via config so this test still exercises the
|
|
# progress-callback path the Slack DM event_message_id threading depends on.
|
|
import yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.SLACK)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.SLACK,
|
|
chat_id="D123",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-3",
|
|
session_key="agent:main:slack:dm:D123",
|
|
event_message_id="1234567890.000001",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
expected_metadata = {
|
|
"thread_id": "1234567890.000001",
|
|
"message_id": "1234567890.000001",
|
|
}
|
|
assert adapter.sent[0]["metadata"] == expected_metadata
|
|
assert all(call["metadata"] == expected_metadata for call in adapter.typing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_carries_anchor_for_relay_discord_auto_thread(monkeypatch, tmp_path):
|
|
"""Relay Discord channel-initiate: the thread doesn't exist at ingest, so
|
|
the connector auto-threads on the reply anchor and stamps
|
|
prospective_thread_id. The tool-progress / status bubbles must carry that
|
|
anchor (reply_to + metadata.reply_to_message_id) so they route into the
|
|
SAME auto-thread as the final reply — otherwise the search-status updates
|
|
leak into the parent channel (staging repro 2026-08-02)."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
import yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.RELAY)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
# Channel-initiating message: no thread_id yet, but the connector stamped
|
|
# the prospective thread id (== the triggering message id). Relay ingress
|
|
# keeps the underlying platform (discord) on the source for display policy,
|
|
# but delivery/progress route through the one live RelayAdapter.
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="chan-parent",
|
|
chat_type="group",
|
|
thread_id=None,
|
|
prospective_thread_id="msg-anchor-1",
|
|
delivered_via_upstream_relay=True,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="find me a gift",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-relay-thread",
|
|
session_key="agent:main:discord:thread:chan-parent:msg-anchor-1",
|
|
event_message_id="msg-anchor-1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent, "expected at least one progress send"
|
|
# Every progress send must carry the anchor so the connector threads it.
|
|
for call in adapter.sent:
|
|
assert call["reply_to"] == "msg-anchor-1", call
|
|
assert (call["metadata"] or {}).get("reply_to_message_id") == "msg-anchor-1", call
|
|
# Discord lifecycle/status sends are marked non-conversational.
|
|
assert (call["metadata"] or {}).get("non_conversational") is True, call
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_no_anchor_for_native_discord_thread_event(monkeypatch, tmp_path):
|
|
"""A message ARRIVING in an existing Discord thread (not the relay
|
|
auto-thread lane) must NOT get the synthetic prospective anchor — it already
|
|
routes by its real thread. Guards against over-broadening the relay fix."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
import yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.RELAY)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
# No prospective_thread_id (event is IN a real thread already).
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="real-thread-9",
|
|
chat_type="thread",
|
|
thread_id="real-thread-9",
|
|
delivered_via_upstream_relay=True,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="continue",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-in-thread",
|
|
session_key="agent:main:discord:thread:real-thread-9:real-thread-9",
|
|
event_message_id="msg-2",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
# The relay-prospective synthetic anchor path must NOT engage; progress
|
|
# routes by the real thread's own metadata, not a forced reply_to anchor.
|
|
for call in adapter.sent:
|
|
meta = call["metadata"] or {}
|
|
# The real thread id drives routing; we did not inject the anchor
|
|
# reply_to that the prospective lane uses.
|
|
assert meta.get("thread_id") == "real-thread-9" or call["reply_to"] != "msg-2", call
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview truncation tests (all/new mode respects tool_preview_length)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_progress_preview(content: str) -> str | None:
|
|
"""Extract the argument-preview portion from a tool-progress message.
|
|
|
|
Handles both render styles:
|
|
- Legacy / custom tools: ``🔧 tool_name: "<preview>"`` (quoted)
|
|
- Friendly built-in verb: ``💻 Running <preview>`` (verb prefix, no quotes)
|
|
"""
|
|
import re
|
|
|
|
# Legacy quoted form takes precedence when present.
|
|
match = re.search(r'"(.+)"', content)
|
|
if match:
|
|
return match.group(1)
|
|
# Friendly form: "<emoji> <verb> <preview>". The terminal verb is "Running".
|
|
marker = " Running "
|
|
idx = content.find(marker)
|
|
if idx != -1:
|
|
return content[idx + len(marker):].strip()
|
|
return None
|
|
|
|
|
|
def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0):
|
|
"""Shared setup for long-preview truncation tests.
|
|
|
|
Returns (adapter, result) after running the agent with LongPreviewAgent.
|
|
``preview_length`` controls display.tool_preview_length in the config file
|
|
that _run_agent reads — so the gateway picks it up the same way production does.
|
|
"""
|
|
import asyncio
|
|
import yaml
|
|
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = LongPreviewAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
# Write config.yaml so _run_agent picks up tool_preview_length
|
|
config = {"display": {"tool_preview_length": preview_length}}
|
|
(tmp_path / "config.yaml").write_text(yaml.dump(config), encoding="utf-8")
|
|
|
|
adapter = ProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-trunc",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
)
|
|
return adapter, result
|
|
|
|
|
|
def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path):
|
|
"""When tool_preview_length is explicitly set (e.g. 120), all/new mode uses that."""
|
|
adapter, result = _run_long_preview_helper(monkeypatch, tmp_path, preview_length=120)
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
content = adapter.sent[0]["content"]
|
|
# With 120-char cap, the command (165 chars) should still be truncated but longer.
|
|
preview_text = _extract_progress_preview(content)
|
|
assert preview_text is not None, f"No preview found in: {content}"
|
|
# Should be longer than the 40-char default
|
|
assert len(preview_text) > 40, f"Preview suspiciously short ({len(preview_text)}): {preview_text}"
|
|
# But still capped at 120
|
|
assert len(preview_text) <= 120, f"Preview too long ({len(preview_text)}): {preview_text}"
|
|
|
|
|
|
def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_path):
|
|
"""The real gateway path must retain the URL beyond its visible cap."""
|
|
import yaml
|
|
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = UrlPreviewAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"tool_preview_length": 0}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
adapter = DiscordProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(
|
|
gateway_run,
|
|
"_resolve_runtime_agent_kwargs",
|
|
lambda: {"api_key": "***"},
|
|
)
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-discord-url",
|
|
session_key="agent:main:discord:dm:12345",
|
|
)
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
visible = UrlPreviewAgent.URL[:37] + "..."
|
|
label = visible.removeprefix("https://")
|
|
assert f"[{label}](<{UrlPreviewAgent.URL}>)" in adapter.sent[0]["content"]
|
|
|
|
|
|
class CommentaryAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.interim_assistant_callback:
|
|
self.interim_assistant_callback("I'll inspect the repo first.", already_streamed=False)
|
|
time.sleep(0.1)
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("done")
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class PreviewedResponseAgent:
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.interim_assistant_callback:
|
|
self.interim_assistant_callback("You're welcome.", already_streamed=False)
|
|
return {
|
|
"final_response": "You're welcome.",
|
|
"response_previewed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class PreviewedSplitAfterCommentaryAgent:
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.session_id = kwargs.get("session_id")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.interim_assistant_callback:
|
|
self.interim_assistant_callback("I'll inspect the repo first.", already_streamed=False)
|
|
self.session_id = f"{self.session_id}-child"
|
|
return {
|
|
"final_response": "Final answer after compression.",
|
|
"response_previewed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class StreamingRefineAgent:
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("Continuing to refine:")
|
|
time.sleep(0.1)
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback(" Final answer.")
|
|
return {
|
|
"final_response": "Continuing to refine: Final answer.",
|
|
"response_previewed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedCommentaryAgent:
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1 and self.interim_assistant_callback:
|
|
self.interim_assistant_callback("I'll inspect the repo first.", already_streamed=False)
|
|
return {
|
|
"final_response": f"final response {type(self).calls}",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedMediaAgent:
|
|
"""Return an explicit image attachment before a queued follow-up."""
|
|
|
|
calls = 0
|
|
media_path = None
|
|
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1:
|
|
final_response = f"first response\nMEDIA:{type(self).media_path}"
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("first response")
|
|
else:
|
|
final_response = "follow-up processed"
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback(final_response)
|
|
return {
|
|
"final_response": final_response,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedSilenceAgent:
|
|
"""First turn is intentionally silent; queued follow-up still runs."""
|
|
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
return {
|
|
"final_response": "NO_REPLY" if type(self).calls == 1 else "follow-up processed",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedFailedEmptyAgent:
|
|
"""First turn fails empty; its normalized error must send before follow-up."""
|
|
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1:
|
|
return {
|
|
"final_response": "",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
"failed": True,
|
|
"error": "provider exploded",
|
|
}
|
|
return {
|
|
"final_response": "follow-up processed",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class BackgroundReviewAgent:
|
|
def __init__(self, **kwargs):
|
|
self.background_review_callback = kwargs.get("background_review_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.background_review_callback:
|
|
self.background_review_callback("💾 Skill 'prospect-scanner' created.")
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class VerboseAgent:
|
|
"""Agent that emits a tool call with args whose JSON exceeds 200 chars."""
|
|
LONG_CODE = "x" * 300
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started", "execute_code", None,
|
|
{"code": self.LONG_CODE},
|
|
)
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
async def _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
agent_cls,
|
|
*,
|
|
session_id,
|
|
pending_text=None,
|
|
config_data=None,
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
adapter_cls=ProgressCaptureAdapter,
|
|
user_id=None,
|
|
scope_id=None,
|
|
):
|
|
if config_data:
|
|
import yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(yaml.dump(config_data), encoding="utf-8")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = agent_cls
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = adapter_cls(platform=platform)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
if config_data and "streaming" in config_data:
|
|
runner.config.streaming = StreamingConfig.from_dict(config_data["streaming"])
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
source = SessionSource(
|
|
platform=platform,
|
|
chat_id=chat_id,
|
|
chat_type=chat_type,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
scope_id=scope_id,
|
|
)
|
|
session_key = f"agent:main:{platform.value}:{chat_type}:{chat_id}"
|
|
if thread_id:
|
|
session_key = f"{session_key}:{thread_id}"
|
|
if pending_text is not None:
|
|
adapter._pending_messages[session_key] = MessageEvent(
|
|
text=pending_text,
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="queued-1",
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id=session_id,
|
|
session_key=session_key,
|
|
)
|
|
return adapter, result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_native_progress_correlates_concurrent_duplicate_tools_by_id(
|
|
monkeypatch, tmp_path
|
|
):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-ids",
|
|
config_data={
|
|
"display": {"platforms": {"slack": {"tool_progress": "off"}}}
|
|
},
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=NativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.native_updates
|
|
second_completed = next(
|
|
update
|
|
for update in adapter.native_updates
|
|
if {task["id"]: task["status"] for task in update["tasks"]}
|
|
== {"call-a": "in_progress", "call-b": "error"}
|
|
)
|
|
assert second_completed["metadata"]["recipient_team_id"] == "T1"
|
|
assert second_completed["metadata"]["recipient_user_id"] == "U1"
|
|
assert adapter.native_updates[-1]["tasks"] == [
|
|
{
|
|
"id": "call-a",
|
|
"title": "web_search - alpha",
|
|
"status": "complete",
|
|
},
|
|
{
|
|
"id": "call-b",
|
|
"title": "web_search - beta",
|
|
"status": "error",
|
|
},
|
|
]
|
|
assert adapter.sent == []
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_native_failure_keeps_editing_one_live_text_fallback(
|
|
monkeypatch, tmp_path
|
|
):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-fallback",
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=FailingNativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert len(adapter.native_updates) == 1
|
|
assert len(adapter.sent) == 1
|
|
assert adapter.sent[0]["content"].endswith("web_search - alpha - running")
|
|
assert len(adapter.edits) >= 2
|
|
assert {edit["message_id"] for edit in adapter.edits} == {"progress-1"}
|
|
assert adapter.edits[-1]["content"].endswith("web_search - beta - error")
|
|
assert "web_search - alpha - complete" in adapter.edits[-1]["content"]
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retryable_overflow_edit_keeps_editable_bubble_identity(monkeypatch, tmp_path):
|
|
"""A transient split edit must retain can_edit and the current message ID."""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
ManyProgressLinesAgent,
|
|
session_id="sess-progress-retry-overflow-same-message",
|
|
config_data={
|
|
"display": {
|
|
"tool_progress": "all",
|
|
"interim_assistant_messages": False,
|
|
}
|
|
},
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
chat_type="direct",
|
|
thread_id="1700000000.000100",
|
|
adapter_cls=RetryableOverflowEditProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert isinstance(adapter, RetryableOverflowEditProgressAdapter)
|
|
assert adapter.retryable_edit_failures == 1
|
|
assert len(adapter.sent) >= 2
|
|
assert adapter.edits[0]["message_id"] == "progress-1"
|
|
assert any(call["message_id"] == "progress-1" for call in adapter.edits[1:])
|
|
assert adapter.oversized_sends == []
|
|
assert adapter.oversized_edits == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_display_streaming_does_not_enable_gateway_streaming(monkeypatch, tmp_path):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
CommentaryAgent,
|
|
session_id="sess-display-streaming-cli-only",
|
|
config_data={
|
|
"display": {
|
|
"streaming": True,
|
|
"interim_assistant_messages": True,
|
|
},
|
|
"streaming": {"enabled": False},
|
|
},
|
|
)
|
|
|
|
assert result.get("already_sent") is not True
|
|
assert adapter.edits == []
|
|
assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."]
|
|
|
|
|
|
class TransformedStreamAgent:
|
|
"""Streams a response, then signals the gateway that a plugin hook
|
|
(``transform_llm_output``) modified the final text after streaming
|
|
finished. ``run_conversation`` returns ``response_transformed=True``
|
|
plus a ``final_response`` that diverges from what was streamed.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("original answer")
|
|
return {
|
|
"final_response": "original answer\n\n[plugin appended this]",
|
|
"response_previewed": True,
|
|
"response_transformed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transformed_response_edits_streamed_message_in_place(monkeypatch, tmp_path):
|
|
"""When a transform_llm_output hook modifies the response after streaming,
|
|
the gateway must edit the existing streamed message in place with the full
|
|
transformed content (so plugins like content filters / appenders reach the
|
|
user) and still mark already_sent=True (no duplicate send).
|
|
"""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
TransformedStreamAgent,
|
|
session_id="sess-transformed-stream",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.MATRIX,
|
|
chat_id="!room:matrix.example.org",
|
|
chat_type="group",
|
|
thread_id="$thread",
|
|
adapter_cls=MetadataEditProgressCaptureAdapter,
|
|
)
|
|
|
|
# Final delivery happened (no duplicate send fallback).
|
|
assert result.get("already_sent") is True
|
|
# The transformed final text reached the user — appended portion is present
|
|
# in an edit_message call (not just in the streamed sends).
|
|
edited_texts = [e["content"] for e in adapter.edits]
|
|
assert any("[plugin appended this]" in text for text in edited_texts), (
|
|
f"expected transformed text in adapter.edits, got: {edited_texts!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monkeypatch, tmp_path):
|
|
QueuedCommentaryAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedCommentaryAgent,
|
|
session_id="sess-queued-commentary",
|
|
pending_text="queued follow-up",
|
|
config_data={"display": {"interim_assistant_messages": True}},
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert result["final_response"] == "final response 2"
|
|
assert "I'll inspect the repo first." in sent_texts
|
|
assert "final response 1" in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_delivers_first_response_media(monkeypatch, tmp_path):
|
|
"""Queued follow-ups must preserve explicit attachments from the first turn."""
|
|
media_path = tmp_path / "queued-first-response.png"
|
|
media_path.write_bytes(b"not-a-real-png-but-a-real-file")
|
|
QueuedMediaAgent.calls = 0
|
|
QueuedMediaAgent.media_path = media_path
|
|
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedMediaAgent,
|
|
session_id="sess-queued-media",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.DISCORD,
|
|
chat_id="discord-thread",
|
|
chat_type="group",
|
|
thread_id="discord-thread",
|
|
adapter_cls=MediaCaptureProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert isinstance(adapter, MediaCaptureProgressAdapter)
|
|
assert {
|
|
"sent_texts": [call["content"] for call in adapter.sent],
|
|
"image_batches": adapter.image_batches,
|
|
} == {
|
|
"sent_texts": ["first response"],
|
|
"image_batches": [
|
|
{
|
|
"chat_id": "discord-thread",
|
|
"images": [(media_path.as_uri(), "")],
|
|
"metadata": {"thread_id": "discord-thread"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_delivers_streamed_first_response_media(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Streaming first-turn text must not suppress its explicit attachment."""
|
|
media_path = tmp_path / "queued-streamed-first-response.png"
|
|
media_path.write_bytes(b"not-a-real-png-but-a-real-file")
|
|
QueuedMediaAgent.calls = 0
|
|
QueuedMediaAgent.media_path = media_path
|
|
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedMediaAgent,
|
|
session_id="sess-queued-streamed-media",
|
|
pending_text="queued follow-up",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.DISCORD,
|
|
chat_id="discord-thread",
|
|
chat_type="group",
|
|
thread_id="discord-thread",
|
|
adapter_cls=MediaCaptureProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert isinstance(adapter, MediaCaptureProgressAdapter)
|
|
all_text = [call["content"] for call in adapter.sent + adapter.edits]
|
|
assert all("MEDIA:" not in text for text in all_text)
|
|
assert adapter.image_batches == [
|
|
{
|
|
"chat_id": "discord-thread",
|
|
"images": [(media_path.as_uri(), "")],
|
|
"metadata": {"thread_id": "discord-thread"},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_suppresses_silent_first_turn_and_processes_queued_followup(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Regression: queued direct-send must not leak NO_REPLY to the channel."""
|
|
QueuedSilenceAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedSilenceAgent,
|
|
session_id="sess-queued-silence",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
thread_id="1712345678.000100",
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert QueuedSilenceAgent.calls == 2
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert "NO_REPLY" not in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_sends_normalized_failure_before_queued_followup(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Queued delivery uses finalized output, not the raw empty agent result."""
|
|
QueuedFailedEmptyAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedFailedEmptyAgent,
|
|
session_id="sess-queued-failed-empty",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
thread_id="1712345678.000100",
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert QueuedFailedEmptyAgent.calls == 2
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert any("The request failed: provider exploded" in text for text in sent_texts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_defers_background_review_notification_until_release(monkeypatch, tmp_path):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
BackgroundReviewAgent,
|
|
session_id="sess-bg-review-order",
|
|
config_data={"display": {"interim_assistant_messages": True}},
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_processing_releases_post_delivery_callback_after_main_send():
|
|
"""Post-delivery callbacks on the adapter fire after the main response."""
|
|
adapter = ProgressCaptureAdapter()
|
|
|
|
async def _handler(event):
|
|
return "done"
|
|
|
|
adapter.set_message_handler(_handler)
|
|
|
|
released = []
|
|
|
|
def _post_delivery_cb():
|
|
released.append(True)
|
|
adapter.sent.append(
|
|
{
|
|
"chat_id": "bg-review",
|
|
"content": "💾 Skill 'prospect-scanner' created.",
|
|
"reply_to": None,
|
|
"metadata": None,
|
|
}
|
|
)
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
)
|
|
event = MessageEvent(
|
|
text="hello",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="msg-1",
|
|
)
|
|
session_key = "agent:main:telegram:group:-1001:17585"
|
|
adapter._active_sessions[session_key] = asyncio.Event()
|
|
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
|
|
|
|
await adapter._process_message_background(event, session_key)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert sent_texts == ["done", "💾 Skill 'prospect-scanner' created."]
|
|
assert released == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_processing_stops_typing_before_hung_post_delivery_callback(
|
|
monkeypatch,
|
|
):
|
|
"""A stuck post-delivery callback must not keep the typing task alive."""
|
|
monkeypatch.setattr(base_platform, "_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS", 0.01)
|
|
adapter = ProgressCaptureAdapter()
|
|
events = []
|
|
|
|
async def _handler(event):
|
|
return "done"
|
|
|
|
async def _post_delivery_cb():
|
|
events.append("callback-start")
|
|
await asyncio.Event().wait()
|
|
|
|
async def _stop_typing(chat_id):
|
|
events.append("typing-stopped")
|
|
await ProgressCaptureAdapter.stop_typing(adapter, chat_id)
|
|
|
|
adapter.set_message_handler(_handler)
|
|
adapter.stop_typing = _stop_typing
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
)
|
|
event = MessageEvent(
|
|
text="hello",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="msg-1",
|
|
)
|
|
session_key = "agent:main:telegram:group:-1001:17585"
|
|
adapter._active_sessions[session_key] = asyncio.Event()
|
|
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
|
|
|
|
await asyncio.wait_for(
|
|
adapter._process_message_background(event, session_key), timeout=1.0
|
|
)
|
|
|
|
assert [call["content"] for call in adapter.sent] == ["done"]
|
|
# Invariant: typing must stop before the (hung) post-delivery callback
|
|
# starts. Don't pin the exact stop_typing call count — the shared
|
|
# cleanup path may make more than one bounded stop attempt.
|
|
assert "typing-stopped" in events
|
|
assert "callback-start" in events
|
|
assert events.index("typing-stopped") < events.index("callback-start")
|
|
assert events[: events.index("callback-start")] == (
|
|
["typing-stopped"] * events.index("callback-start")
|
|
)
|
|
assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path):
|
|
import yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"tool_progress": "all"}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = DelayedProgressAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal tool metadata
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="dm-1",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
session_key = "agent:main:discord:dm:dm-1"
|
|
runner._session_run_generation[session_key] = 1
|
|
|
|
original_send = adapter.send
|
|
invalidated = {"done": False}
|
|
|
|
async def send_and_invalidate(chat_id, content, reply_to=None, metadata=None):
|
|
result = await original_send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
if "first command" in content and not invalidated["done"]:
|
|
invalidated["done"] = True
|
|
runner._invalidate_session_run_generation(session_key, reason="test_stop")
|
|
return result
|
|
|
|
adapter.send = send_and_invalidate
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-progress-stop",
|
|
session_key=session_key,
|
|
run_generation=1,
|
|
)
|
|
|
|
all_progress_text = " ".join(call["content"] for call in adapter.sent)
|
|
all_progress_text += " ".join(call["content"] for call in adapter.edits)
|
|
assert result["final_response"] == "done"
|
|
assert 'first command' in all_progress_text
|
|
assert 'second command' not in all_progress_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_drops_interim_commentary_after_generation_invalidation(monkeypatch, tmp_path):
|
|
import yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = DelayedInterimAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="dm-2",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
session_key = "agent:main:discord:dm:dm-2"
|
|
runner._session_run_generation[session_key] = 1
|
|
|
|
original_send = adapter.send
|
|
invalidated = {"done": False}
|
|
|
|
async def send_and_invalidate(chat_id, content, reply_to=None, metadata=None):
|
|
result = await original_send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
if content == "first interim" and not invalidated["done"]:
|
|
invalidated["done"] = True
|
|
runner._invalidate_session_run_generation(session_key, reason="test_stop")
|
|
return result
|
|
|
|
adapter.send = send_and_invalidate
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-commentary-stop",
|
|
session_key=session_key,
|
|
run_generation=1,
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert result["final_response"] == "done"
|
|
assert "first interim" in sent_texts
|
|
assert "second interim" not in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keep_typing_stops_immediately_when_interrupt_event_is_set():
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
stop_event = asyncio.Event()
|
|
|
|
task = asyncio.create_task(
|
|
adapter._keep_typing(
|
|
"dm-typing-stop",
|
|
interval=30.0,
|
|
stop_event=stop_event,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
stop_event.set()
|
|
await asyncio.wait_for(task, timeout=0.5)
|
|
|
|
normal_typing_calls = [
|
|
call for call in adapter.typing if call.get("metadata") != {"stopped": True}
|
|
]
|
|
stopped_calls = [
|
|
call for call in adapter.typing if call.get("metadata") == {"stopped": True}
|
|
]
|
|
assert len(normal_typing_calls) == 1
|
|
assert len(stopped_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path):
|
|
"""Verbose mode with default tool_preview_length (0) should NOT truncate args.
|
|
|
|
Previously, verbose mode capped args at 200 chars when tool_preview_length
|
|
was 0 (default). The user explicitly opted into verbose — show full detail.
|
|
"""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
VerboseAgent,
|
|
session_id="sess-verbose-no-truncate",
|
|
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 0}},
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
# The full 300-char 'x' string should be present, not truncated to 200
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
assert VerboseAgent.LONG_CODE in all_content
|
|
|
|
|
|
class CodeBlockProgressAdapter(ProgressCaptureAdapter):
|
|
"""A markdown-capable progress adapter (declares supports_code_blocks)."""
|
|
|
|
supports_code_blocks = True
|
|
|
|
|
|
class TerminalCommandAgent:
|
|
"""Emits a terminal tool.started with a real, multi-line command arg."""
|
|
|
|
CMD = (
|
|
"set -euo pipefail\n"
|
|
"printf 'node: '; node --version\n"
|
|
"npm install -g hyperframes@latest"
|
|
)
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started", "terminal", self.CMD, {"command": self.CMD}
|
|
)
|
|
# Let the async progress task drain the queue and send before returning.
|
|
time.sleep(0.35)
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
|
|
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
|
|
renders a bare fenced code block — no language tag (Slack mrkdwn would print
|
|
'bash' as a literal first code line). In non-verbose ("all"/"new") mode the
|
|
command is collapsed to a single line capped at tool_preview_length so a long
|
|
or multi-line command doesn't render as a huge block (#42634)."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = TerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-code-block",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
# Bare fenced block, no language tag (no '```bash').
|
|
assert "```" in all_content
|
|
assert "```bash" not in all_content
|
|
# Non-verbose collapses to the first line + truncation marker — the later
|
|
# command lines must NOT appear (this was the "huge block" regression).
|
|
assert "set -euo pipefail" in all_content
|
|
assert "npm install -g hyperframes@latest" not in all_content
|
|
assert "node --version" not in all_content
|
|
# No truncated quoted preview for the terminal command.
|
|
assert 'terminal: "' not in all_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):
|
|
"""Verbose mode on a markdown-capable gateway renders the FULL multi-line
|
|
command in a bare fenced block (no truncation, no 'bash' tag). This is the
|
|
parity guarantee for #42634: verbose keeps full detail, non-verbose caps."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = TerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-code-block-verbose",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
assert "```" in all_content
|
|
assert "```bash" not in all_content
|
|
# Full command body present — verbose is uncapped.
|
|
assert "npm install -g hyperframes@latest" in all_content
|
|
assert "node --version" in all_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):
|
|
"""#41215 also rendered the bash block in verbose mode. The revert removed it
|
|
from both branches, so verbose progress must not emit a fenced ```bash block
|
|
either (verbose still shows args by opt-in, just not as a code block)."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = TerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-verbose-no-bash",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
assert "```bash" not in all_content
|
|
|
|
class MultiTerminalCommandAgent:
|
|
"""Emits several consecutive terminal tool.started events, then a
|
|
different tool, then terminal again — to exercise header collapsing."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
cb("tool.started", "terminal", "echo one", {"command": "echo one"})
|
|
cb("tool.started", "terminal", "echo two", {"command": "echo two"})
|
|
cb("tool.started", "terminal", "echo three", {"command": "echo three"})
|
|
cb("tool.started", "web_search", "query stuff", {"query": "query stuff"})
|
|
cb("tool.started", "terminal", "echo four", {"command": "echo four"})
|
|
time.sleep(0.35)
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consecutive_terminal_progress_collapses_headers(monkeypatch, tmp_path):
|
|
"""Back-to-back terminal calls render ONE "terminal" header followed by
|
|
adjacent code blocks; a different tool in between resets the header so the
|
|
next terminal call gets a fresh one."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = MultiTerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-consecutive",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
contents = [call["content"] for call in adapter.sent] + [
|
|
call["content"] for call in adapter.edits
|
|
]
|
|
final = max(contents, key=len) if contents else ""
|
|
# All four commands present as code blocks.
|
|
for cmd in ("echo one", "echo two", "echo three", "echo four"):
|
|
assert cmd in final
|
|
# Exactly TWO terminal headers: one for the first run of three calls,
|
|
# one for the terminal call after web_search broke the streak.
|
|
assert final.count("terminal\n```") == 2
|
|
|
|
|
|
class TestSlackReplyInThreadProgressRouting:
|
|
"""#18859: reply_in_thread=false must stop progress from creating threads."""
|
|
|
|
def test_slack_reply_in_thread_false_drops_synthetic_thread(self):
|
|
from gateway.run import _resolve_progress_thread_id
|
|
|
|
# source.thread_id == event ts is the adapter's synthetic
|
|
# session-keying thread for top-level messages — not a real thread.
|
|
assert _resolve_progress_thread_id(
|
|
Platform.SLACK,
|
|
source_thread_id="1700000000.000100",
|
|
event_message_id="1700000000.000100",
|
|
reply_in_thread=False,
|
|
) is None
|
|
|
|
def test_buzz_uses_event_message_id_as_progress_thread(self):
|
|
"""Buzz has no native thread_id; progress must reply-to the trigger."""
|
|
from gateway.run import _resolve_progress_thread_id
|
|
|
|
assert _resolve_progress_thread_id(
|
|
"buzz",
|
|
source_thread_id=None,
|
|
event_message_id="evt-trigger-001",
|
|
reply_in_thread=True,
|
|
) == "evt-trigger-001"
|