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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions agent/title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
def generate_title(
user_message: str,
assistant_response: str,
timeout: float = 30.0,
timeout: Optional[float] = None,
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
) -> Optional[str]:
Expand Down Expand Up @@ -92,6 +92,7 @@ def auto_title_session(
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
timeout: Optional[float] = None,
) -> None:
"""Generate and set a session title if one doesn't already exist.

Expand All @@ -113,7 +114,7 @@ def auto_title_session(
return

title = generate_title(
user_message, assistant_response, failure_callback=failure_callback, main_runtime=main_runtime
user_message, assistant_response, timeout=timeout, failure_callback=failure_callback, main_runtime=main_runtime
)
if not title:
return
Expand All @@ -139,6 +140,7 @@ def maybe_auto_title(
failure_callback: Optional[FailureCallback] = None,
main_runtime: dict = None,
title_callback: Optional[TitleCallback] = None,
timeout: Optional[float] = None,
) -> None:
"""Fire-and-forget title generation after the first exchange.

Expand All @@ -164,6 +166,7 @@ def maybe_auto_title(
"failure_callback": failure_callback,
"main_runtime": main_runtime,
"title_callback": title_callback,
"timeout": timeout,
},
daemon=True,
name="auto-title",
Expand Down
25 changes: 24 additions & 1 deletion gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,22 @@ def _normalize_whatsapp_id(value: Optional[str]) -> str:
normalized = normalized.replace(":", "@", 1)
return normalized

@staticmethod
def _normalize_outgoing_chat_id(chat_id: str) -> str:
"""Normalize a chat ID for outgoing WhatsApp messages.

Bare phone numbers (e.g. ``15005004144``) are appended with
``@s.whatsapp.net`` so the bridge can parse them via ``jidDecode``.
IDs that already contain ``@`` are passed through unchanged.
"""
cid = str(chat_id).strip()
if not cid:
return cid
if "@" not in cid:
if re.fullmatch(r"\+?\d+", cid):
cid = f"{cid}@s.whatsapp.net"
return cid

def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
bot_ids = set()
for candidate in data.get("botIds") or []:
Expand Down Expand Up @@ -929,6 +945,8 @@ async def send(
if not content or not content.strip():
return SendResult(success=True, message_id=None)

chat_id = self._normalize_outgoing_chat_id(chat_id)

try:
import aiohttp

Expand Down Expand Up @@ -1016,6 +1034,9 @@ async def _send_media_to_bridge(
bridge_exit = await self._check_managed_bridge_exit()
if bridge_exit:
return SendResult(success=False, error=bridge_exit)

chat_id = self._normalize_outgoing_chat_id(chat_id)

try:
import aiohttp

Expand Down Expand Up @@ -1119,7 +1140,9 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
return
if await self._check_managed_bridge_exit():
return


chat_id = self._normalize_outgoing_chat_id(chat_id)

try:
import aiohttp

Expand Down
4 changes: 4 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18731,8 +18731,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
"Gateway auto-title failure suppressed (not user-visible): %s: %s",
task, exc,
)
from agent.auxiliary_client import _get_task_timeout
_title_timeout = _get_task_timeout("title_generation")

maybe_auto_title_kwargs = {
"failure_callback": _title_failure_cb,
"timeout": _title_timeout,
"main_runtime": {
"model": getattr(agent, "model", None),
"provider": getattr(agent, "provider", None),
Expand Down
80 changes: 80 additions & 0 deletions tests/agent/test_title_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,38 @@ def test_no_callback_matches_legacy_behavior(self):
with patch("agent.title_generator.call_llm", side_effect=RuntimeError("nope")):
assert generate_title("q", "a") is None

def test_default_timeout_is_none(self):
"""generate_title defaults timeout to None so call_llm reads config."""
captured = {}

def mock_call_llm(**kwargs):
captured["timeout"] = kwargs.get("timeout")
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "Title"
return resp

with patch("agent.title_generator.call_llm", side_effect=mock_call_llm):
generate_title("q", "a")

assert captured["timeout"] is None

def test_explicit_timeout_passed_to_call_llm(self):
"""An explicit timeout value is forwarded to call_llm."""
captured = {}

def mock_call_llm(**kwargs):
captured["timeout"] = kwargs.get("timeout")
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = "Title"
return resp

with patch("agent.title_generator.call_llm", side_effect=mock_call_llm):
generate_title("q", "a", timeout=120.0)

assert captured["timeout"] == 120.0

def test_truncates_long_messages(self):
"""Long user/assistant messages should be truncated in the LLM request."""
captured_kwargs = {}
Expand Down Expand Up @@ -157,6 +189,28 @@ def test_skips_if_generation_fails(self):
auto_title_session(db, "sess-1", "hi", "hello")
db.set_session_title.assert_not_called()

def test_forwards_timeout_to_generate_title(self):
"""auto_title_session must forward timeout to generate_title."""
db = MagicMock()
db.get_session_title.return_value = None

with patch("agent.title_generator.generate_title", return_value="T") as gen:
auto_title_session(db, "sess-1", "hi", "hello", timeout=60.0)
gen.assert_called_once_with(
"hi", "hello", timeout=60.0, failure_callback=None, main_runtime=None
)

def test_default_timeout_is_none(self):
"""auto_title_session defaults timeout to None."""
db = MagicMock()
db.get_session_title.return_value = None

with patch("agent.title_generator.generate_title", return_value="T") as gen:
auto_title_session(db, "sess-1", "hi", "hello")
gen.assert_called_once_with(
"hi", "hello", timeout=None, failure_callback=None, main_runtime=None
)


class TestMaybeAutoTitle:
"""Tests for maybe_auto_title() — the fire-and-forget entry point."""
Expand Down Expand Up @@ -202,6 +256,7 @@ def test_fires_on_first_exchange(self):
failure_callback=None,
main_runtime=None,
title_callback=None,
timeout=None,
)

def test_forwards_failure_callback_to_worker(self):
Expand All @@ -228,6 +283,31 @@ def _cb(task, exc):
failure_callback=_cb,
main_runtime=None,
title_callback=None,
timeout=None,
)

def test_forwards_timeout_to_worker(self):
"""maybe_auto_title must forward timeout into the thread."""
db = MagicMock()
db.get_session_title.return_value = None
history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]

with patch("agent.title_generator.auto_title_session") as mock_auto:
maybe_auto_title(db, "sess-1", "hello", "hi there", history, timeout=120.0)
import time
time.sleep(0.3)
mock_auto.assert_called_once_with(
db,
"sess-1",
"hello",
"hi there",
failure_callback=None,
main_runtime=None,
title_callback=None,
timeout=120.0,
)

def test_skips_if_no_response(self):
Expand Down
47 changes: 47 additions & 0 deletions tests/gateway/test_whatsapp_normalize_chat_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Tests for WhatsApp outgoing chat ID normalization."""

import pytest

from gateway.platforms.whatsapp import WhatsAppAdapter


class TestNormalizeOutgoingChatId:
"""Tests for _normalize_outgoing_chat_id()."""

def test_bare_phone_gets_suffix(self):
assert (
WhatsAppAdapter._normalize_outgoing_chat_id("15005004144")
== "15005004144@s.whatsapp.net"
)

def test_phone_with_plus_gets_suffix(self):
assert (
WhatsAppAdapter._normalize_outgoing_chat_id("+15005004144")
== "+15005004144@s.whatsapp.net"
)

def test_full_jid_passthrough(self):
cid = "15005004144@s.whatsapp.net"
assert WhatsAppAdapter._normalize_outgoing_chat_id(cid) == cid

def test_group_jid_passthrough(self):
cid = "120363044444444444@g.us"
assert WhatsAppAdapter._normalize_outgoing_chat_id(cid) == cid

def test_empty_string_passthrough(self):
assert WhatsAppAdapter._normalize_outgoing_chat_id("") == ""

def test_whitespace_stripped(self):
assert (
WhatsAppAdapter._normalize_outgoing_chat_id(" 15005004144 ")
== "15005004144@s.whatsapp.net"
)

def test_group_id_with_dash_not_normalized(self):
"""Group IDs like '15005004144-1234567890' should NOT get @s.whatsapp.net."""
cid = "15005004144-1234567890"
assert WhatsAppAdapter._normalize_outgoing_chat_id(cid) == cid

def test_status_broadcast_passthrough(self):
cid = "status@broadcast"
assert WhatsAppAdapter._normalize_outgoing_chat_id(cid) == cid
Loading