From a53ea93b1789b1b45fa77261999c12d384e76b04 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 21:27:23 -0700 Subject: [PATCH] =?UTF-8?q?fix(ai=5Fmail):=20preserve=20multi-line=20reply?= =?UTF-8?q?/send=20bodies=20=E2=80=94=20join=20args[1:]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply and send silently truncated multi-line bodies to the first CLI arg. handle_reply(args[1]) and parse_send_args(rest[1]) dropped args[2:]/rest[2:] when a body word-split into multiple args. Now join all remaining args. Backwards-compatible; single-arg messages unchanged. +6 tests (718 total). Found via @hooks replies arriving as first-line-only (60/48 chars). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai_mail/apps/handlers/email/send_args.py | 2 +- src/aipass/ai_mail/apps/modules/email.py | 3 +- .../docs/s84_multiline_reply_truncation.md | 47 +++++++++++++++ src/aipass/ai_mail/tests/test_email_module.py | 59 +++++++++++++++++++ src/aipass/ai_mail/tests/test_reply.py | 35 +++++++++++ src/aipass/ai_mail/tests/test_send_helpers.py | 31 ++++++++++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md diff --git a/src/aipass/ai_mail/apps/handlers/email/send_args.py b/src/aipass/ai_mail/apps/handlers/email/send_args.py index f03010ca..77752b14 100644 --- a/src/aipass/ai_mail/apps/handlers/email/send_args.py +++ b/src/aipass/ai_mail/apps/handlers/email/send_args.py @@ -103,7 +103,7 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]: if recipients and len(rest) >= 2: mode = "direct" subject = rest[0] - message = rest[1] + message = " ".join(rest[1:]) elif not recipients and not rest: mode = "interactive" subject = None diff --git a/src/aipass/ai_mail/apps/modules/email.py b/src/aipass/ai_mail/apps/modules/email.py index 90e9e28b..fd3c5372 100644 --- a/src/aipass/ai_mail/apps/modules/email.py +++ b/src/aipass/ai_mail/apps/modules/email.py @@ -277,7 +277,8 @@ def handle_reply(args: List[str]) -> bool: if not original: error(f"Message not found: {args[0]}") return True - success, message, reply_id = send_reply(branch_path, original, args[1]) + reply_message = " ".join(args[1:]) + success, message, reply_id = send_reply(branch_path, original, reply_message) if success: console.print(f"[green]{message}[/green]") else: diff --git a/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md b/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md new file mode 100644 index 00000000..474564f6 --- /dev/null +++ b/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md @@ -0,0 +1,47 @@ +# S84: Multi-line Reply Body Truncation — Root Cause & Fix + +## Bug + +Reply and send commands silently truncate multi-line message bodies to the first argument. + +**Reported:** @devpulse dispatch 7b6a70b9 (2026-06-08) +**Evidence:** @hooks sent two replies with full multi-line bodies; both arrived in devpulse inbox as first line only (60 chars / 48 chars). @memory's reply arrived intact (951 chars). + +## Root Cause + +Two code paths only captured the second positional CLI argument as the message body, dropping everything after it: + +1. **`email.py:handle_reply`** (line 280): + ```python + send_reply(branch_path, original, args[1]) # args[2:] silently dropped + ``` + +2. **`send_args.py:parse_send_args`** (line 106): + ```python + message = rest[1] # rest[2:] silently dropped + ``` + +When an agent's bash command produces multiple args from a message body (shell word-splitting on unquoted text, or subprocess argument handling), only the first segment survives. The rest is discarded with no warning. + +The entire Python delivery pipeline (reply.py, delivery.py, create.py) handles multi-line strings correctly — the truncation happens at the CLI argument boundary. + +## Why @memory Worked + +@memory's reply body was a single properly-quoted argument that arrived as one `args[1]` entry. @hooks' body was split into multiple args (likely unquoted or shell-expanded), so only the first piece reached `send_reply()`. + +## Fix + +Both locations now join all remaining args: + +1. **`email.py:handle_reply`**: `reply_message = " ".join(args[1:])` +2. **`send_args.py:parse_send_args`**: `message = " ".join(rest[1:])` + +Backwards-compatible: single-arg messages pass through unchanged. Multi-arg messages are reconstructed. + +## Tests Added (6) + +- `test_reply.py`: `test_send_reply_multiline_body_preserved` — multi-line body stored intact in delivery and sent copy +- `test_email_module.py`: `TestHandleReplyMultiArg` — handle_reply joins split args; single arg unchanged +- `test_send_helpers.py`: 3 tests — parse_send_args joins split message; single arg unchanged; embedded newlines preserved + +718 tests pass (712 + 6 new). diff --git a/src/aipass/ai_mail/tests/test_email_module.py b/src/aipass/ai_mail/tests/test_email_module.py index fd719161..2f683ecf 100644 --- a/src/aipass/ai_mail/tests/test_email_module.py +++ b/src/aipass/ai_mail/tests/test_email_module.py @@ -1761,3 +1761,62 @@ class TestSendInteractiveExtended: assert result is True assert any("@alpha" in p for p in printed) assert any("sent" in p.lower() for p in printed) + + +class TestHandleReplyMultiArg: + """Regression tests for multi-line reply body truncation (S84 fix).""" + + def test_reply_joins_split_args_into_body(self, tmp_path, monkeypatch): + """When shell splits body into multiple args, all are joined into message.""" + original = {"id": "msg1", "from": "@devpulse", "subject": "test dispatch"} + captured_msg = [] + + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email._resolve_branch_path", + lambda: tmp_path, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.get_email_by_id", + lambda inbox_file, msg_id: original, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.send_reply", + lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:], + ) + mock_console = MagicMock() + monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console) + _write_inbox(tmp_path) + + from aipass.ai_mail.apps.modules.email import handle_reply + + result = handle_reply(["msg1", "Line one", "Line two", "Line three"]) + assert result is True + assert len(captured_msg) == 1 + assert captured_msg[0] == "Line one Line two Line three" + + def test_reply_single_arg_unchanged(self, tmp_path, monkeypatch): + """Single-arg reply body remains unchanged (no extra spaces).""" + original = {"id": "msg1", "from": "@devpulse", "subject": "test"} + captured_msg = [] + + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email._resolve_branch_path", + lambda: tmp_path, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.get_email_by_id", + lambda inbox_file, msg_id: original, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.send_reply", + lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:], + ) + mock_console = MagicMock() + monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console) + _write_inbox(tmp_path) + + from aipass.ai_mail.apps.modules.email import handle_reply + + result = handle_reply(["msg1", "Complete single-line reply"]) + assert result is True + assert captured_msg[0] == "Complete single-line reply" diff --git a/src/aipass/ai_mail/tests/test_reply.py b/src/aipass/ai_mail/tests/test_reply.py index f289c87c..77fd7401 100644 --- a/src/aipass/ai_mail/tests/test_reply.py +++ b/src/aipass/ai_mail/tests/test_reply.py @@ -268,3 +268,38 @@ def test_send_reply_re_prefix_not_duplicated(tmp_path): assert success is True # Should keep "RE: Already replied", not "RE: RE: Already replied" assert deliver_calls[0][1]["subject"] == "RE: Already replied" + + +def test_send_reply_multiline_body_preserved(tmp_path): + """Multi-line reply body is stored intact, not truncated to first line.""" + from_branch_path = tmp_path / "hooks" + from_branch_path.mkdir() + sender_info = {"email": "@hooks", "name": "HOOKS"} + target_branch = {"email": "@devpulse", "name": "DEVPULSE", "path": str(tmp_path / "devpulse")} + original = _make_original_email() + + deliver_calls = [] + + def mock_deliver(to_branch, email_data): + deliver_calls.append((to_branch, email_data)) + return (True, "") + + multiline_body = "Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding" + + with ( + patch(_PATCH_BRANCH_DETECTION, return_value=sender_info), + patch(_PATCH_DELIVERY, side_effect=mock_deliver), + patch(_PATCH_ALL_BRANCHES, return_value=[target_branch]), + patch(_PATCH_CLOSE_ARCHIVE, return_value=(True, "closed")), + ): + success, _message, _reply_id = send_reply(from_branch_path, original, multiline_body) + + assert success is True + assert deliver_calls[0][1]["message"] == multiline_body + + sent_folder = from_branch_path / ".ai_mail.local" / "sent" + sent_files = list(sent_folder.glob("*.json")) + assert len(sent_files) == 1 + with open(sent_files[0], "r", encoding="utf-8") as f: + sent_data = json.load(f) + assert sent_data["message"] == multiline_body diff --git a/src/aipass/ai_mail/tests/test_send_helpers.py b/src/aipass/ai_mail/tests/test_send_helpers.py index 5b16014b..3e467f7a 100644 --- a/src/aipass/ai_mail/tests/test_send_helpers.py +++ b/src/aipass/ai_mail/tests/test_send_helpers.py @@ -357,3 +357,34 @@ def test_resolve_dispatch_target_tilde_path(): result = resolve_dispatch_target("~/Projects/flow", True, get_branch_info_fn=None) assert result == "@flow" + + +# ---- parse_send_args multi-arg message tests (S84 fix) -------- + + +def test_parse_send_args_joins_split_message(): + """When message body is split into multiple args, all are joined.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + result = parse_send_args(["@target", "Subject", "Line one", "Line two", "Line three"]) + assert result["mode"] == "direct" + assert result["subject"] == "Subject" + assert result["message"] == "Line one Line two Line three" + + +def test_parse_send_args_single_message_unchanged(): + """Single message arg is not altered.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + result = parse_send_args(["@target", "Subject", "Complete body here"]) + assert result["mode"] == "direct" + assert result["message"] == "Complete body here" + + +def test_parse_send_args_multiline_body_preserved(): + """A single arg with embedded newlines passes through intact.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + body = "First line\nSecond line\nThird line" + result = parse_send_args(["@target", "Subject", body]) + assert result["message"] == body