From 0b73263fc6637f253f74782dcf93705c6b60343b Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sun, 26 Apr 2026 21:41:48 -0700 Subject: [PATCH] =?UTF-8?q?fix(ai=5Fmail):=20DPLAN-0158=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20explicit=20reply=20instructions=20in=20daemon=20pro?= =?UTF-8?q?mpt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced ambiguous "Send confirmation when done" with explicit drone @ai_mail reply command including the dispatch email ID and sender address. Sanitizes interpolated metadata (ID must be alnum ≤12, sender must match @word pattern). Fallback generic instruction when ID is missing. 3 new tests. Co-Authored-By: Claude Opus 4.6 --- .../ai_mail/apps/handlers/dispatch/daemon.py | 22 +++- src/aipass/ai_mail/tests/test_daemon.py | 122 ++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py index fdbd4f81..2f832c1b 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -313,9 +313,25 @@ def spawn_agent( lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") - # Prompt — never interpolate sender-controlled content into the prompt. - # The agent reads inbox.json as data, not as instructions (DPLAN-0155 M1). - prompt = "Hi. Check your inbox for new dispatch emails and execute the task. Send confirmation when done." + # Prompt — only interpolate system-generated metadata (id, sender email). + # Free-form fields (subject, body) stay in inbox.json (DPLAN-0155 M1). + msg_id = message.get("id", "") + safe_id = msg_id if msg_id.isalnum() and len(msg_id) <= 12 else "" + sender_addr = message.get("from", "") + safe_sender = sender_addr if sender_addr.startswith("@") and sender_addr[1:].replace("_", "").isalnum() else "" + + if safe_id: + reply_cmd = f'drone @ai_mail reply {safe_id} "your results summary"' + reply_instr = f" When done, reply via: {reply_cmd}. This is required — do not skip the reply step." + else: + reply_instr = ( + " When done, reply to the dispatch email via drone @ai_mail reply with your results." + " This is required — do not skip the reply step." + ) + + sender_note = f" Dispatch from {safe_sender}." if safe_sender else "" + + prompt = "Hi. Check inbox, process new emails, update memories when done." + sender_note + reply_instr claude_cmd = [ "claude", diff --git a/src/aipass/ai_mail/tests/test_daemon.py b/src/aipass/ai_mail/tests/test_daemon.py index ac414d12..a97d3bc5 100644 --- a/src/aipass/ai_mail/tests/test_daemon.py +++ b/src/aipass/ai_mail/tests/test_daemon.py @@ -1335,6 +1335,128 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch): assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon" +def test_spawn_agent_prompt_includes_reply_id(tmp_path): + """Prompt includes explicit reply command with the dispatch email ID.""" + branch_path = tmp_path / "branch" + branch_path.mkdir() + (branch_path / "logs").mkdir() + + message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"} + config = {"max_turns_per_wake": 50} + state = {"daily_counts": {}, "session_cycles": {}} + + captured_cmd = [] + + def capture_popen(cmd, *args, **kwargs): + captured_cmd.extend(cmd) + mock_proc = MagicMock() + mock_proc.pid = 99999 + return mock_proc + + with ( + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", + side_effect=capture_popen, + ), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", + return_value=(True, "Lock acquired"), + ), + patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", + create=True, + ), + ): + spawn_agent(branch_path, "@testbranch", message, config, state) + + prompt_idx = captured_cmd.index("-p") + 1 + prompt = captured_cmd[prompt_idx] + assert "drone @ai_mail reply abc12345" in prompt + assert "required" in prompt.lower() + + +def test_spawn_agent_prompt_includes_sender(tmp_path): + """Prompt includes sender address from the dispatch email.""" + branch_path = tmp_path / "branch" + branch_path.mkdir() + (branch_path / "logs").mkdir() + + message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"} + config = {"max_turns_per_wake": 50} + state = {"daily_counts": {}, "session_cycles": {}} + + captured_cmd = [] + + def capture_popen(cmd, *args, **kwargs): + captured_cmd.extend(cmd) + mock_proc = MagicMock() + mock_proc.pid = 99999 + return mock_proc + + with ( + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", + side_effect=capture_popen, + ), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", + return_value=(True, "Lock acquired"), + ), + patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", + create=True, + ), + ): + spawn_agent(branch_path, "@testbranch", message, config, state) + + prompt_idx = captured_cmd.index("-p") + 1 + prompt = captured_cmd[prompt_idx] + assert "@devpulse" in prompt + + +def test_spawn_agent_prompt_fallback_without_id(tmp_path): + """Without a valid ID, prompt uses generic reply instruction.""" + branch_path = tmp_path / "branch" + branch_path.mkdir() + (branch_path / "logs").mkdir() + + message = {"from": "@devpulse", "id": "", "subject": "Test task"} + config = {"max_turns_per_wake": 50} + state = {"daily_counts": {}, "session_cycles": {}} + + captured_cmd = [] + + def capture_popen(cmd, *args, **kwargs): + captured_cmd.extend(cmd) + mock_proc = MagicMock() + mock_proc.pid = 99999 + return mock_proc + + with ( + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen", + side_effect=capture_popen, + ), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock", + return_value=(True, "Lock acquired"), + ), + patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"), + patch( + "aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification", + create=True, + ), + ): + spawn_agent(branch_path, "@testbranch", message, config, state) + + prompt_idx = captured_cmd.index("-p") + 1 + prompt = captured_cmd[prompt_idx] + assert "reply " in prompt + assert "required" in prompt.lower() + + # ---- run_daemon tests -------------------------------------------