mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: improve WhatsApp UX — chunking, formatting, streaming (#8723)
Three changes that address the poor WhatsApp experience reported by users: 1. Reclassify WhatsApp from TIER_LOW to TIER_MEDIUM in display_config.py — enables streaming and tool progress via the existing Baileys /edit bridge endpoint. Users now see progressive responses instead of minutes of silence followed by a wall of text. 2. Lower MAX_MESSAGE_LENGTH from 65536 to 4096 and add proper chunking — send() now calls format_message() and truncate_message() before sending, then loops through chunks with a small delay between them. The base class truncate_message() already handles code block boundary detection (closes/reopens fences at chunk boundaries). reply_to is only set on the first chunk. 3. Override format_message() with WhatsApp-specific markdown conversion — converts **bold** to *bold*, ~~strike~~ to ~strike~, headers to bold text, and [links](url) to text (url). Code blocks and inline code are protected from conversion via placeholder substitution. Together these fix the two user complaints: - 'sends the whole code all the time' → now chunked at 4K with proper formatting - 'terminal gets interrupted and gets cooked' → streaming + tool progress give visual feedback so users don't accidentally interrupt with follow-up messages
This commit is contained in:
parent
5fae356a85
commit
15b1a3aa69
4 changed files with 378 additions and 30 deletions
|
|
@ -82,7 +82,7 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||||
|
|
||||||
# Tier 3 — no edit support, progress messages are permanent
|
# Tier 3 — no edit support, progress messages are permanent
|
||||||
"signal": _TIER_LOW,
|
"signal": _TIER_LOW,
|
||||||
"whatsapp": _TIER_LOW,
|
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
|
||||||
"bluebubbles": _TIER_LOW,
|
"bluebubbles": _TIER_LOW,
|
||||||
"weixin": _TIER_LOW,
|
"weixin": _TIER_LOW,
|
||||||
"wecom": _TIER_LOW,
|
"wecom": _TIER_LOW,
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,9 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
- session_path: Path to store WhatsApp session data
|
- session_path: Path to store WhatsApp session data
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# WhatsApp message limits
|
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||||
MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages
|
# WhatsApp allows ~65K but long messages are unreadable on mobile.
|
||||||
|
MAX_MESSAGE_LENGTH = 4096
|
||||||
|
|
||||||
# Default bridge location relative to the hermes-agent install
|
# Default bridge location relative to the hermes-agent install
|
||||||
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
|
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
|
||||||
|
|
@ -531,6 +532,63 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
self._close_bridge_log()
|
self._close_bridge_log()
|
||||||
print(f"[{self.name}] Disconnected")
|
print(f"[{self.name}] Disconnected")
|
||||||
|
|
||||||
|
def format_message(self, content: str) -> str:
|
||||||
|
"""Convert standard markdown to WhatsApp-compatible formatting.
|
||||||
|
|
||||||
|
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
|
||||||
|
and monospaced `inline`. Standard markdown uses different syntax
|
||||||
|
for bold/italic/strikethrough, so we convert here.
|
||||||
|
|
||||||
|
Code blocks (``` fenced) and inline code (`) are protected from
|
||||||
|
conversion via placeholder substitution.
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return content
|
||||||
|
|
||||||
|
# --- 1. Protect fenced code blocks from formatting changes ---
|
||||||
|
_FENCE_PH = "\x00FENCE"
|
||||||
|
fences: list[str] = []
|
||||||
|
|
||||||
|
def _save_fence(m: re.Match) -> str:
|
||||||
|
fences.append(m.group(0))
|
||||||
|
return f"{_FENCE_PH}{len(fences) - 1}\x00"
|
||||||
|
|
||||||
|
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
|
||||||
|
|
||||||
|
# --- 2. Protect inline code ---
|
||||||
|
_CODE_PH = "\x00CODE"
|
||||||
|
codes: list[str] = []
|
||||||
|
|
||||||
|
def _save_code(m: re.Match) -> str:
|
||||||
|
codes.append(m.group(0))
|
||||||
|
return f"{_CODE_PH}{len(codes) - 1}\x00"
|
||||||
|
|
||||||
|
result = re.sub(r"`[^`\n]+`", _save_code, result)
|
||||||
|
|
||||||
|
# --- 3. Convert markdown formatting to WhatsApp syntax ---
|
||||||
|
# Bold: **text** or __text__ → *text*
|
||||||
|
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
|
||||||
|
result = re.sub(r"__(.+?)__", r"*\1*", result)
|
||||||
|
# Strikethrough: ~~text~~ → ~text~
|
||||||
|
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
|
||||||
|
# Italic: *text* is already WhatsApp italic — leave as-is
|
||||||
|
# _text_ is already WhatsApp italic — leave as-is
|
||||||
|
|
||||||
|
# --- 4. Convert markdown headers to bold text ---
|
||||||
|
# # Header → *Header*
|
||||||
|
result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# --- 5. Convert markdown links: [text](url) → text (url) ---
|
||||||
|
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
|
||||||
|
|
||||||
|
# --- 6. Restore protected sections ---
|
||||||
|
for i, fence in enumerate(fences):
|
||||||
|
result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
|
||||||
|
for i, code in enumerate(codes):
|
||||||
|
result = result.replace(f"{_CODE_PH}{i}\x00", code)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
|
@ -538,38 +596,57 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||||
reply_to: Optional[str] = None,
|
reply_to: Optional[str] = None,
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send a message via the WhatsApp bridge."""
|
"""Send a message via the WhatsApp bridge.
|
||||||
|
|
||||||
|
Formats markdown for WhatsApp, splits long messages into chunks
|
||||||
|
that preserve code block boundaries, and sends each chunk sequentially.
|
||||||
|
"""
|
||||||
if not self._running or not self._http_session:
|
if not self._running or not self._http_session:
|
||||||
return SendResult(success=False, error="Not connected")
|
return SendResult(success=False, error="Not connected")
|
||||||
bridge_exit = await self._check_managed_bridge_exit()
|
bridge_exit = await self._check_managed_bridge_exit()
|
||||||
if bridge_exit:
|
if bridge_exit:
|
||||||
return SendResult(success=False, error=bridge_exit)
|
return SendResult(success=False, error=bridge_exit)
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
return SendResult(success=True, message_id=None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
payload = {
|
# Format and chunk the message
|
||||||
"chatId": chat_id,
|
formatted = self.format_message(content)
|
||||||
"message": content,
|
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||||
}
|
|
||||||
if reply_to:
|
last_message_id = None
|
||||||
payload["replyTo"] = reply_to
|
for chunk in chunks:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
async with self._http_session.post(
|
"chatId": chat_id,
|
||||||
f"http://127.0.0.1:{self._bridge_port}/send",
|
"message": chunk,
|
||||||
json=payload,
|
}
|
||||||
timeout=aiohttp.ClientTimeout(total=30)
|
if reply_to and last_message_id is None:
|
||||||
) as resp:
|
# Only reply-to on the first chunk
|
||||||
if resp.status == 200:
|
payload["replyTo"] = reply_to
|
||||||
data = await resp.json()
|
|
||||||
return SendResult(
|
async with self._http_session.post(
|
||||||
success=True,
|
f"http://127.0.0.1:{self._bridge_port}/send",
|
||||||
message_id=data.get("messageId"),
|
json=payload,
|
||||||
raw_response=data
|
timeout=aiohttp.ClientTimeout(total=30)
|
||||||
)
|
) as resp:
|
||||||
else:
|
if resp.status == 200:
|
||||||
error = await resp.text()
|
data = await resp.json()
|
||||||
return SendResult(success=False, error=error)
|
last_message_id = data.get("messageId")
|
||||||
|
else:
|
||||||
|
error = await resp.text()
|
||||||
|
return SendResult(success=False, error=error)
|
||||||
|
|
||||||
|
# Small delay between chunks to avoid rate limiting
|
||||||
|
if len(chunks) > 1:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
return SendResult(
|
||||||
|
success=True,
|
||||||
|
message_id=last_message_id,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return SendResult(success=False, error=str(e))
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,14 +189,14 @@ class TestPlatformDefaults:
|
||||||
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
|
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
|
||||||
from gateway.display_config import resolve_display_setting
|
from gateway.display_config import resolve_display_setting
|
||||||
|
|
||||||
for plat in ("slack", "mattermost", "matrix", "feishu"):
|
for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"):
|
||||||
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
|
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
|
||||||
|
|
||||||
def test_low_tier_platforms(self):
|
def test_low_tier_platforms(self):
|
||||||
"""Signal, WhatsApp, etc. default to 'off' tool progress."""
|
"""Signal, BlueBubbles, etc. default to 'off' tool progress."""
|
||||||
from gateway.display_config import resolve_display_setting
|
from gateway.display_config import resolve_display_setting
|
||||||
|
|
||||||
for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"):
|
for plat in ("signal", "bluebubbles", "weixin", "wecom", "dingtalk"):
|
||||||
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
|
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
|
||||||
|
|
||||||
def test_minimal_tier_platforms(self):
|
def test_minimal_tier_platforms(self):
|
||||||
|
|
|
||||||
271
tests/gateway/test_whatsapp_formatting.py
Normal file
271
tests/gateway/test_whatsapp_formatting.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Tests for WhatsApp message formatting and chunking.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- format_message(): markdown → WhatsApp syntax conversion
|
||||||
|
- send(): message chunking for long responses
|
||||||
|
- MAX_MESSAGE_LENGTH: practical UX limit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_adapter():
|
||||||
|
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||||
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||||
|
|
||||||
|
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||||
|
adapter.platform = Platform.WHATSAPP
|
||||||
|
adapter.config = MagicMock()
|
||||||
|
adapter.config.extra = {}
|
||||||
|
adapter._bridge_port = 3000
|
||||||
|
adapter._bridge_script = "/tmp/test-bridge.js"
|
||||||
|
adapter._session_path = MagicMock()
|
||||||
|
adapter._bridge_log_fh = None
|
||||||
|
adapter._bridge_log = None
|
||||||
|
adapter._bridge_process = None
|
||||||
|
adapter._reply_prefix = None
|
||||||
|
adapter._running = True
|
||||||
|
adapter._message_handler = None
|
||||||
|
adapter._fatal_error_code = None
|
||||||
|
adapter._fatal_error_message = None
|
||||||
|
adapter._fatal_error_retryable = True
|
||||||
|
adapter._fatal_error_handler = None
|
||||||
|
adapter._active_sessions = {}
|
||||||
|
adapter._pending_messages = {}
|
||||||
|
adapter._background_tasks = set()
|
||||||
|
adapter._auto_tts_disabled_chats = set()
|
||||||
|
adapter._message_queue = asyncio.Queue()
|
||||||
|
adapter._http_session = MagicMock()
|
||||||
|
adapter._mention_patterns = []
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncCM:
|
||||||
|
"""Minimal async context manager returning a fixed value."""
|
||||||
|
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# format_message tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFormatMessage:
|
||||||
|
"""WhatsApp markdown conversion."""
|
||||||
|
|
||||||
|
def test_bold_double_asterisk(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("**hello**") == "*hello*"
|
||||||
|
|
||||||
|
def test_bold_double_underscore(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("__hello__") == "*hello*"
|
||||||
|
|
||||||
|
def test_strikethrough(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
||||||
|
|
||||||
|
def test_headers_converted_to_bold(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("# Title") == "*Title*"
|
||||||
|
assert adapter.format_message("## Subtitle") == "*Subtitle*"
|
||||||
|
assert adapter.format_message("### Deep") == "*Deep*"
|
||||||
|
|
||||||
|
def test_links_converted(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
result = adapter.format_message("[click here](https://example.com)")
|
||||||
|
assert result == "click here (https://example.com)"
|
||||||
|
|
||||||
|
def test_code_blocks_protected(self):
|
||||||
|
"""Code blocks should not have their content reformatted."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
content = "before **bold** ```python\n**not bold**\n``` after **bold**"
|
||||||
|
result = adapter.format_message(content)
|
||||||
|
assert "```python\n**not bold**\n```" in result
|
||||||
|
assert result.startswith("before *bold*")
|
||||||
|
assert result.endswith("after *bold*")
|
||||||
|
|
||||||
|
def test_inline_code_protected(self):
|
||||||
|
"""Inline code should not have its content reformatted."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
content = "use `**raw**` here"
|
||||||
|
result = adapter.format_message(content)
|
||||||
|
assert "`**raw**`" in result
|
||||||
|
assert result.startswith("use ")
|
||||||
|
|
||||||
|
def test_empty_content(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("") == ""
|
||||||
|
assert adapter.format_message(None) is None
|
||||||
|
|
||||||
|
def test_plain_text_unchanged(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
assert adapter.format_message("hello world") == "hello world"
|
||||||
|
|
||||||
|
def test_already_whatsapp_italic(self):
|
||||||
|
"""Single *italic* should pass through unchanged."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
# After bold conversion, *text* is WhatsApp italic
|
||||||
|
assert adapter.format_message("*italic*") == "*italic*"
|
||||||
|
|
||||||
|
def test_multiline_mixed(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
content = "# Header\n\n**Bold text** and ~~strike~~\n\n```\ncode\n```"
|
||||||
|
result = adapter.format_message(content)
|
||||||
|
assert "*Header*" in result
|
||||||
|
assert "*Bold text*" in result
|
||||||
|
assert "~strike~" in result
|
||||||
|
assert "```\ncode\n```" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MAX_MESSAGE_LENGTH tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMessageLimits:
|
||||||
|
"""WhatsApp message length limits."""
|
||||||
|
|
||||||
|
def test_max_message_length_is_practical(self):
|
||||||
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||||
|
assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# send() chunking tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSendChunking:
|
||||||
|
"""WhatsApp send() splits long messages into chunks."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_short_message_single_send(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
resp = MagicMock(status=200)
|
||||||
|
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||||
|
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||||
|
|
||||||
|
result = await adapter.send("chat1", "short message")
|
||||||
|
assert result.success
|
||||||
|
# Only one call to bridge /send
|
||||||
|
assert adapter._http_session.post.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_long_message_chunked(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
resp = MagicMock(status=200)
|
||||||
|
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||||
|
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||||
|
|
||||||
|
# Create a message longer than MAX_MESSAGE_LENGTH (4096)
|
||||||
|
long_msg = "a " * 3000 # ~6000 chars
|
||||||
|
|
||||||
|
result = await adapter.send("chat1", long_msg)
|
||||||
|
assert result.success
|
||||||
|
# Should have made multiple calls
|
||||||
|
assert adapter._http_session.post.call_count > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_message_no_send(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
result = await adapter.send("chat1", "")
|
||||||
|
assert result.success
|
||||||
|
assert adapter._http_session.post.call_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_whitespace_only_no_send(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
result = await adapter.send("chat1", " \n ")
|
||||||
|
assert result.success
|
||||||
|
assert adapter._http_session.post.call_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_applied_before_send(self):
|
||||||
|
"""Markdown should be converted to WhatsApp format before sending."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
resp = MagicMock(status=200)
|
||||||
|
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||||
|
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||||
|
|
||||||
|
await adapter.send("chat1", "**bold text**")
|
||||||
|
|
||||||
|
# Check the payload sent to the bridge
|
||||||
|
call_args = adapter._http_session.post.call_args
|
||||||
|
payload = call_args.kwargs.get("json") or call_args[1].get("json")
|
||||||
|
assert payload["message"] == "*bold text*"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_to_only_on_first_chunk(self):
|
||||||
|
"""reply_to should only be set on the first chunk."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
resp = MagicMock(status=200)
|
||||||
|
resp.json = AsyncMock(return_value={"messageId": "msg1"})
|
||||||
|
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||||
|
|
||||||
|
long_msg = "word " * 2000 # ~10000 chars, multiple chunks
|
||||||
|
|
||||||
|
await adapter.send("chat1", long_msg, reply_to="orig123")
|
||||||
|
|
||||||
|
calls = adapter._http_session.post.call_args_list
|
||||||
|
assert len(calls) > 1
|
||||||
|
|
||||||
|
# First chunk should have replyTo
|
||||||
|
first_payload = calls[0].kwargs.get("json") or calls[0][1].get("json")
|
||||||
|
assert first_payload.get("replyTo") == "orig123"
|
||||||
|
|
||||||
|
# Subsequent chunks should NOT have replyTo
|
||||||
|
for call in calls[1:]:
|
||||||
|
payload = call.kwargs.get("json") or call[1].get("json")
|
||||||
|
assert "replyTo" not in payload
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bridge_error_returns_failure(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
resp = MagicMock(status=500)
|
||||||
|
resp.text = AsyncMock(return_value="Internal Server Error")
|
||||||
|
adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp))
|
||||||
|
|
||||||
|
result = await adapter.send("chat1", "hello")
|
||||||
|
assert not result.success
|
||||||
|
assert "Internal Server Error" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_connected_returns_failure(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._running = False
|
||||||
|
|
||||||
|
result = await adapter.send("chat1", "hello")
|
||||||
|
assert not result.success
|
||||||
|
assert "Not connected" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# display_config tier classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestWhatsAppTier:
|
||||||
|
"""WhatsApp should be classified as TIER_MEDIUM."""
|
||||||
|
|
||||||
|
def test_whatsapp_streaming_follows_global(self):
|
||||||
|
from gateway.display_config import resolve_display_setting
|
||||||
|
# TIER_MEDIUM has streaming: None (follow global), not False
|
||||||
|
assert resolve_display_setting({}, "whatsapp", "streaming") is None
|
||||||
|
|
||||||
|
def test_whatsapp_tool_progress_is_new(self):
|
||||||
|
from gateway.display_config import resolve_display_setting
|
||||||
|
assert resolve_display_setting({}, "whatsapp", "tool_progress") == "new"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue