diff --git a/agent/title_generator.py b/agent/title_generator.py index a7f1e158e1a6..62a98639b05e 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -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]: @@ -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. @@ -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 @@ -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. @@ -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", diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 593922011508..07527421ecd9 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -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 []: @@ -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 @@ -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 @@ -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 diff --git a/gateway/run.py b/gateway/run.py index 48613e3b4ce9..5db3eda4d0d7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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), diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 56286f6ecc99..37884248c831 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -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 = {} @@ -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.""" @@ -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): @@ -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): diff --git a/tests/gateway/test_whatsapp_normalize_chat_id.py b/tests/gateway/test_whatsapp_normalize_chat_id.py new file mode 100644 index 000000000000..915d933d0a42 --- /dev/null +++ b/tests/gateway/test_whatsapp_normalize_chat_id.py @@ -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