diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 39d4e537eb..afcbf1a7e4 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -99,6 +99,7 @@ def _normalize_server_url(raw: str) -> str: class BlueBubblesAdapter(BasePlatformAdapter): platform = Platform.BLUEBUBBLES + SUPPORTS_MESSAGE_EDITING = False MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH def __init__(self, config: PlatformConfig): @@ -391,6 +392,13 @@ class BlueBubblesAdapter(BasePlatformAdapter): # Text sending # ------------------------------------------------------------------ + @staticmethod + def truncate_message(content: str, max_length: int = MAX_TEXT_LENGTH) -> List[str]: + # Use the base splitter but skip pagination indicators — iMessage + # bubbles flow naturally without "(1/3)" suffixes. + chunks = BasePlatformAdapter.truncate_message(content, max_length) + return [re.sub(r"\s*\(\d+/\d+\)$", "", c) for c in chunks] + async def send( self, chat_id: str, @@ -398,10 +406,19 @@ class BlueBubblesAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - text = strip_markdown(content or "") + text = self.format_message(content) if not text: return SendResult(success=False, error="BlueBubbles send requires text") - chunks = self.truncate_message(text, max_length=self.MAX_MESSAGE_LENGTH) + # Split on paragraph breaks first (double newlines) so each thought + # becomes its own iMessage bubble, then truncate any that are still + # too long. + paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()] + chunks: List[str] = [] + for para in (paragraphs or [text]): + if len(para) <= self.MAX_MESSAGE_LENGTH: + chunks.append(para) + else: + chunks.extend(self.truncate_message(para, max_length=self.MAX_MESSAGE_LENGTH)) last = SendResult(success=True) for chunk in chunks: guid = await self._resolve_chat_guid(chat_id) diff --git a/gateway/session.py b/gateway/session.py index 0584cd7acb..fe12e6ab32 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -285,6 +285,18 @@ def build_session_context_prompt( "Do not promise to perform these actions. If the user asks, explain " "that you can only read messages sent directly to you and respond." ) + elif context.source.platform == Platform.BLUEBUBBLES: + lines.append("") + lines.append( + "**Platform notes:** You are responding via iMessage. " + "Keep responses short and conversational — think texts, not essays. " + "Structure longer replies as separate short thoughts, each separated " + "by a blank line (double newline). Each block between blank lines " + "will be delivered as its own iMessage bubble, so write accordingly: " + "one idea per bubble, 1–3 sentences each. " + "If the user needs a detailed answer, give the short version first " + "and offer to elaborate." + ) # Connected platforms platforms_list = ["local (files on this machine)"] diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 86b4ac3512..e3ff26cc69 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -66,6 +66,37 @@ class TestBlueBubblesHelpers: assert check_bluebubbles_requirements() is True + def test_supports_message_editing_is_false(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + assert adapter.SUPPORTS_MESSAGE_EDITING is False + + def test_truncate_message_omits_pagination_suffixes(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + chunks = adapter.truncate_message("abcdefghij", max_length=6) + assert len(chunks) > 1 + assert "".join(chunks) == "abcdefghij" + assert all("(" not in chunk for chunk in chunks) + + @pytest.mark.asyncio + async def test_send_splits_paragraphs_into_multiple_bubbles(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + sent = [] + + async def fake_resolve_chat_guid(chat_id): + return "iMessage;-;user@example.com" + + async def fake_api_post(path, payload): + sent.append(payload["message"]) + return {"data": {"guid": f"msg-{len(sent)}"}} + + monkeypatch.setattr(adapter, "_resolve_chat_guid", fake_resolve_chat_guid) + monkeypatch.setattr(adapter, "_api_post", fake_api_post) + + result = await adapter.send("user@example.com", "first thought\n\nsecond thought") + + assert result.success is True + assert sent == ["first thought", "second thought"] + def test_format_message_strips_markdown(self, monkeypatch): adapter = _make_adapter(monkeypatch) assert adapter.format_message("**Hello** `world`") == "Hello world" diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 59e9fa0408..49fb91d449 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -58,6 +58,13 @@ class ProgressCaptureAdapter(BasePlatformAdapter): return {"id": chat_id} +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): self.tool_progress_callback = kwargs.get("tool_progress_callback") @@ -502,6 +509,7 @@ async def _run_with_agent( chat_id="-1001", chat_type="group", thread_id="17585", + adapter_cls=ProgressCaptureAdapter, ): if config_data: import yaml @@ -516,7 +524,7 @@ async def _run_with_agent( fake_run_agent.AIAgent = agent_cls monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) - adapter = ProgressCaptureAdapter(platform=platform) + adapter = adapter_cls(platform=platform) runner = _make_runner(adapter) gateway_run = importlib.import_module("gateway.run") if config_data and "streaming" in config_data: @@ -666,6 +674,26 @@ async def test_run_agent_interim_commentary_works_with_tool_progress_off(monkeyp assert any(call["content"] == "I'll inspect the repo first." for call in adapter.sent) +@pytest.mark.asyncio +async def test_run_agent_bluebubbles_uses_commentary_send_path_for_quick_replies(monkeypatch, tmp_path): + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + CommentaryAgent, + session_id="sess-bluebubbles-commentary", + config_data={"display": {"interim_assistant_messages": True}}, + platform=Platform.BLUEBUBBLES, + chat_id="iMessage;-;user@example.com", + chat_type="dm", + thread_id=None, + adapter_cls=NonEditingProgressCaptureAdapter, + ) + + assert result.get("already_sent") is not True + assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."] + assert adapter.edits == [] + + @pytest.mark.asyncio async def test_run_agent_previewed_final_marks_already_sent(monkeypatch, tmp_path): adapter, result = await _run_with_agent( diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index e82336bc3c..deeb55940a 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -185,6 +185,25 @@ class TestBuildSessionContextPrompt: assert "Telegram" in prompt assert "Home Chat" in prompt + def test_bluebubbles_prompt_mentions_short_conversational_i_message_format(self): + config = GatewayConfig( + platforms={ + Platform.BLUEBUBBLES: PlatformConfig(enabled=True, extra={"server_url": "http://localhost:1234", "password": "secret"}), + }, + ) + source = SessionSource( + platform=Platform.BLUEBUBBLES, + chat_id="iMessage;-;user@example.com", + chat_name="Ben", + chat_type="dm", + ) + ctx = build_session_context(source, config) + prompt = build_session_context_prompt(ctx) + + assert "responding via iMessage" in prompt + assert "short and conversational" in prompt + assert "blank line" in prompt + def test_discord_prompt(self): config = GatewayConfig( platforms={