Merge pull request #481 from AIOSAI/work/ai_mail_dplan0158
fix(ai_mail): DPLAN-0158 Phase 1 — explicit reply in daemon prompt
This commit is contained in:
@@ -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 <id> 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",
|
||||
|
||||
@@ -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 <id>" in prompt
|
||||
assert "required" in prompt.lower()
|
||||
|
||||
|
||||
# ---- run_daemon tests -------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user