fix(ai_mail): preserve multi-line reply/send bodies — join args[1:]
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user