feat(emailgen): Ollama-driven fake email worker for IMAP/POP3 deckies
Second orchestrator worker (decnet emailgen) that drips persona-driven, threaded, multi-language fake emails into running mail deckies. Personas live on Topology.email_personas; topology-wide language_default falls through to any persona that doesn't pin its own. Em-dashes are suppressed at the prompt layer by default and only lifted for personas explicitly marked uses_llms_heavily — em-dashes are an LLM tell and a flat corpus of em-dashed mail is a giveaway. EML delivery writes into /var/spool/decnet-emails/<thread>/<msg>.eml on the mail decky via docker exec; wiring the IMAP/POP3 templates to read from that spool (replacing the hardcoded _BAIT_EMAILS) is the next step.
This commit is contained in:
0
tests/orchestrator/emailgen/__init__.py
Normal file
0
tests/orchestrator/emailgen/__init__.py
Normal file
169
tests/orchestrator/emailgen/test_driver.py
Normal file
169
tests/orchestrator/emailgen/test_driver.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""EmailDriver: stub the Ollama subprocess + docker exec; verify EML
|
||||
parse-and-repair and payload metadata."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.orchestrator.drivers import email as email_driver
|
||||
from decnet.orchestrator.emailgen.personas import EmailPersona
|
||||
from decnet.orchestrator.emailgen.scheduler import EmailAction
|
||||
|
||||
|
||||
def _persona(name="John", email="john@corp.com"):
|
||||
return EmailPersona(
|
||||
name=name,
|
||||
email=email,
|
||||
role="COO",
|
||||
tone="formal",
|
||||
mannerisms=["uses 'Best regards'"],
|
||||
language="en",
|
||||
)
|
||||
|
||||
|
||||
def _action(is_reply=False):
|
||||
return EmailAction(
|
||||
mail_decky_uuid="d1",
|
||||
mail_decky_name="mailhost",
|
||||
mail_decky_services=("imap",),
|
||||
sender=_persona(),
|
||||
recipient=_persona(name="Sarah", email="sarah@corp.com"),
|
||||
thread_id="thr1",
|
||||
parent_message_id="<old@corp.com>" if is_reply else None,
|
||||
references="" if not is_reply else "<old@corp.com>",
|
||||
subject_hint="Re: budget" if is_reply else None,
|
||||
parent_excerpt=None,
|
||||
context_hint="Q3 budget" if not is_reply else "Re: budget",
|
||||
is_reply=is_reply,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_subject_and_body_extracts_subject_line():
|
||||
out = "Subject: Quick update\n\nHi Sarah,\nNumbers attached.\n"
|
||||
subject, body = email_driver._parse_subject_and_body(out)
|
||||
assert subject == "Quick update"
|
||||
assert body.startswith("Hi Sarah")
|
||||
|
||||
|
||||
def test_parse_subject_strips_code_fences():
|
||||
out = "```\nSubject: Quick update\n\nbody\n```\n"
|
||||
subject, body = email_driver._parse_subject_and_body(out)
|
||||
assert subject == "Quick update"
|
||||
assert body == "body"
|
||||
|
||||
|
||||
def test_parse_subject_falls_back_when_missing():
|
||||
out = "Just a body, no subject\n"
|
||||
subject, body = email_driver._parse_subject_and_body(out)
|
||||
assert subject == "Business Communication"
|
||||
assert "body" in body.lower()
|
||||
|
||||
|
||||
def test_build_eml_includes_required_headers():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
eml = email_driver._build_eml(
|
||||
sender_name="John",
|
||||
sender_email="john@corp.com",
|
||||
recipient_name="Sarah",
|
||||
recipient_email="sarah@corp.com",
|
||||
subject="Q3 budget",
|
||||
body="Hi Sarah,\nNumbers attached.",
|
||||
message_id="<m1@corp.com>",
|
||||
in_reply_to=None,
|
||||
references="",
|
||||
ts=datetime(2026, 4, 26, 12, 0, tzinfo=timezone.utc),
|
||||
).decode("utf-8")
|
||||
assert "From: John <john@corp.com>" in eml
|
||||
assert "To: Sarah <sarah@corp.com>" in eml
|
||||
assert "Subject: Q3 budget" in eml
|
||||
assert "Message-ID: <m1@corp.com>" in eml
|
||||
assert "MIME-Version: 1.0" in eml
|
||||
assert "In-Reply-To" not in eml
|
||||
|
||||
|
||||
def test_build_eml_threads_carry_in_reply_to_and_references():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
eml = email_driver._build_eml(
|
||||
sender_name="John",
|
||||
sender_email="john@corp.com",
|
||||
recipient_name="Sarah",
|
||||
recipient_email="sarah@corp.com",
|
||||
subject="Re: Q3",
|
||||
body="Following up.",
|
||||
message_id="<m2@corp.com>",
|
||||
in_reply_to="<m1@corp.com>",
|
||||
references="<m1@corp.com>",
|
||||
ts=datetime(2026, 4, 26, 12, 0, tzinfo=timezone.utc),
|
||||
).decode("utf-8")
|
||||
assert "In-Reply-To: <m1@corp.com>" in eml
|
||||
assert "References: <m1@corp.com>" in eml
|
||||
|
||||
|
||||
def test_container_for_imap_takes_priority():
|
||||
assert email_driver._container_for("mailhost", ["imap", "pop3"]) == "mailhost-imap"
|
||||
|
||||
|
||||
def test_container_for_pop3_only():
|
||||
assert email_driver._container_for("mailhost", ["pop3"]) == "mailhost-pop3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_driver_run_success_path(monkeypatch):
|
||||
"""Stub both subprocess calls (ollama + docker exec) as success."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def fake_run_capture(argv, *, stdin_data=None, timeout=8.0):
|
||||
calls.append(list(argv))
|
||||
if argv[0] == "ollama":
|
||||
return 0, "Subject: Q3 budget\n\nHi Sarah,\nNumbers attached.\n", ""
|
||||
# docker exec
|
||||
return 0, "", ""
|
||||
|
||||
monkeypatch.setattr(email_driver, "_run_capture", fake_run_capture)
|
||||
|
||||
drv = email_driver.EmailDriver(model="llama3.1", ollama_timeout=1.0)
|
||||
result = await drv.run(_action())
|
||||
assert result.success is True
|
||||
assert result.payload["model"] == "llama3.1"
|
||||
assert result.payload["subject"] == "Q3 budget"
|
||||
assert result.payload["language"] == "en"
|
||||
assert result.payload["mannerisms_used"]
|
||||
assert result.payload["message_id"].startswith("<")
|
||||
assert result.payload["eml_path"].endswith(".eml")
|
||||
assert result.payload["container"] == "mailhost-imap"
|
||||
# Two subprocess calls: ollama, then docker exec.
|
||||
assert calls[0][0] == "ollama"
|
||||
assert calls[1][0] == "docker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_driver_run_ollama_failure_short_circuits(monkeypatch):
|
||||
async def fake_run_capture(argv, *, stdin_data=None, timeout=8.0):
|
||||
if argv[0] == "ollama":
|
||||
return 1, "", "ollama: model not found"
|
||||
return 0, "", ""
|
||||
|
||||
monkeypatch.setattr(email_driver, "_run_capture", fake_run_capture)
|
||||
|
||||
drv = email_driver.EmailDriver()
|
||||
result = await drv.run(_action())
|
||||
assert result.success is False
|
||||
assert result.payload["stage"] == "ollama"
|
||||
assert "model not found" in result.payload["stderr"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_driver_run_delivery_failure(monkeypatch):
|
||||
async def fake_run_capture(argv, *, stdin_data=None, timeout=8.0):
|
||||
if argv[0] == "ollama":
|
||||
return 0, "Subject: hi\n\nbody\n", ""
|
||||
return 1, "", "no such container"
|
||||
|
||||
monkeypatch.setattr(email_driver, "_run_capture", fake_run_capture)
|
||||
|
||||
drv = email_driver.EmailDriver()
|
||||
result = await drv.run(_action())
|
||||
assert result.success is False
|
||||
assert result.payload["stage"] == "delivery"
|
||||
assert "no such container" in result.payload["stderr"]
|
||||
72
tests/orchestrator/emailgen/test_events.py
Normal file
72
tests/orchestrator/emailgen/test_events.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""events.to_row / topic_for / event_type_for."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.orchestrator.drivers.base import ActivityResult
|
||||
from decnet.orchestrator.emailgen import events
|
||||
from decnet.orchestrator.emailgen.personas import EmailPersona
|
||||
from decnet.orchestrator.emailgen.scheduler import EmailAction
|
||||
|
||||
|
||||
def _persona(email="john@corp.com"):
|
||||
return EmailPersona(
|
||||
name="John", email=email, role="COO", tone="formal",
|
||||
mannerisms=[], language="en",
|
||||
)
|
||||
|
||||
|
||||
def _action():
|
||||
return EmailAction(
|
||||
mail_decky_uuid="d1",
|
||||
mail_decky_name="mailhost",
|
||||
mail_decky_services=("imap",),
|
||||
sender=_persona(),
|
||||
recipient=_persona(email="sarah@corp.com"),
|
||||
thread_id="thr1",
|
||||
parent_message_id=None,
|
||||
references="",
|
||||
subject_hint=None,
|
||||
parent_excerpt=None,
|
||||
context_hint="Q3 budget",
|
||||
is_reply=False,
|
||||
)
|
||||
|
||||
|
||||
def test_to_row_pulls_message_id_subject_from_payload():
|
||||
res = ActivityResult(
|
||||
success=True,
|
||||
payload={
|
||||
"message_id": "<m1@corp.com>",
|
||||
"subject": "Q3 budget",
|
||||
"language": "en",
|
||||
"eml_path": "/var/spool/decnet-emails/thr1/m1.eml",
|
||||
"model": "llama3.1",
|
||||
},
|
||||
)
|
||||
row = events.to_row(_action(), res)
|
||||
assert row["mail_decky_uuid"] == "d1"
|
||||
assert row["thread_id"] == "thr1"
|
||||
assert row["message_id"] == "<m1@corp.com>"
|
||||
assert row["subject"] == "Q3 budget"
|
||||
assert row["sender_email"] == "john@corp.com"
|
||||
assert row["recipient_email"] == "sarah@corp.com"
|
||||
assert row["language"] == "en"
|
||||
assert row["eml_path"].endswith(".eml")
|
||||
assert row["success"] is True
|
||||
assert row["payload"]["model"] == "llama3.1"
|
||||
|
||||
|
||||
def test_to_row_falls_back_to_persona_language():
|
||||
res = ActivityResult(success=True, payload={})
|
||||
row = events.to_row(_action(), res)
|
||||
assert row["language"] == "en"
|
||||
assert row["message_id"] == ""
|
||||
|
||||
|
||||
def test_topic_for_uses_orchestrator_email_root():
|
||||
topic = events.topic_for(_action())
|
||||
assert topic == f"orchestrator.{_topics.ORCHESTRATOR_EMAIL}.d1"
|
||||
|
||||
|
||||
def test_event_type_for_returns_email_constant():
|
||||
assert events.event_type_for(_action()) == _topics.ORCHESTRATOR_EMAIL
|
||||
101
tests/orchestrator/emailgen/test_personas.py
Normal file
101
tests/orchestrator/emailgen/test_personas.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Persona schema parsing + active-hours window tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from decnet.orchestrator.emailgen.personas import (
|
||||
EmailPersona,
|
||||
in_active_hours,
|
||||
parse_personas,
|
||||
)
|
||||
|
||||
|
||||
def _persona(**over) -> dict:
|
||||
base = {
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_parse_empty_inputs():
|
||||
assert parse_personas(None) == []
|
||||
assert parse_personas("") == []
|
||||
assert parse_personas([]) == []
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty_no_raise():
|
||||
assert parse_personas("{not json") == []
|
||||
|
||||
|
||||
def test_parse_invalid_top_level_shape_returns_empty():
|
||||
assert parse_personas('{"not": "a list"}') == []
|
||||
|
||||
|
||||
def test_parse_drops_invalid_entry_keeps_valid():
|
||||
raw = json.dumps([
|
||||
_persona(),
|
||||
{"name": "broken", "email": "not-an-email"},
|
||||
_persona(name="Sarah", email="sarah@corp.com"),
|
||||
])
|
||||
parsed = parse_personas(raw)
|
||||
assert len(parsed) == 2
|
||||
assert {p.name for p in parsed} == {"John Smith", "Sarah"}
|
||||
|
||||
|
||||
def test_parse_resolves_language_default_when_unset():
|
||||
raw = json.dumps([_persona()])
|
||||
parsed = parse_personas(raw, language_default="es")
|
||||
assert parsed[0].language == "es"
|
||||
|
||||
|
||||
def test_parse_persona_language_overrides_default():
|
||||
raw = json.dumps([_persona(language="pt")])
|
||||
parsed = parse_personas(raw, language_default="es")
|
||||
assert parsed[0].language == "pt"
|
||||
|
||||
|
||||
def test_parse_accepts_python_list_directly():
|
||||
parsed = parse_personas([_persona()])
|
||||
assert len(parsed) == 1
|
||||
|
||||
|
||||
def test_uses_llms_heavily_default_false():
|
||||
parsed = parse_personas([_persona()])
|
||||
assert parsed[0].uses_llms_heavily is False
|
||||
|
||||
|
||||
def test_uses_llms_heavily_can_be_set():
|
||||
parsed = parse_personas([_persona(uses_llms_heavily=True)])
|
||||
assert parsed[0].uses_llms_heavily is True
|
||||
|
||||
|
||||
def test_active_hours_normal_window():
|
||||
p = EmailPersona(**_persona(active_hours="09:00-18:00"))
|
||||
assert in_active_hours(p, 12) is True
|
||||
assert in_active_hours(p, 8) is False
|
||||
assert in_active_hours(p, 18) is False
|
||||
assert in_active_hours(p, 9) is True
|
||||
|
||||
|
||||
def test_active_hours_wraparound_window():
|
||||
p = EmailPersona(**_persona(active_hours="22:00-06:00"))
|
||||
assert in_active_hours(p, 23) is True
|
||||
assert in_active_hours(p, 0) is True
|
||||
assert in_active_hours(p, 5) is True
|
||||
assert in_active_hours(p, 7) is False
|
||||
|
||||
|
||||
def test_active_hours_malformed_treats_as_always_on():
|
||||
p = EmailPersona(**_persona(active_hours="garbage"))
|
||||
assert in_active_hours(p, 0) is True
|
||||
assert in_active_hours(p, 23) is True
|
||||
|
||||
|
||||
def test_active_hours_equal_window_treated_as_always_on():
|
||||
p = EmailPersona(**_persona(active_hours="10:00-10:00"))
|
||||
assert in_active_hours(p, 5) is True
|
||||
152
tests/orchestrator/emailgen/test_prompt.py
Normal file
152
tests/orchestrator/emailgen/test_prompt.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Prompt builder behaviour: language constraint, em-dash suppression,
|
||||
deterministic mannerism injection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from decnet.orchestrator.emailgen.personas import EmailPersona
|
||||
from decnet.orchestrator.emailgen.prompt import (
|
||||
PromptInputs,
|
||||
build,
|
||||
select_mannerisms,
|
||||
)
|
||||
|
||||
|
||||
def _persona(**over) -> EmailPersona:
|
||||
base = dict(
|
||||
name="John Smith",
|
||||
email="john@corp.com",
|
||||
role="COO",
|
||||
tone="formal",
|
||||
mannerisms=[
|
||||
"opens with 'I hope this finds you well'",
|
||||
"uses 'Best regards' exclusively",
|
||||
"references policy by number",
|
||||
"ccs legal",
|
||||
],
|
||||
language="en",
|
||||
)
|
||||
base.update(over)
|
||||
return EmailPersona(**base)
|
||||
|
||||
|
||||
class _SeededRng:
|
||||
"""Adapter so prompt code thinks it has a SystemRandom."""
|
||||
|
||||
def __init__(self, seed: int):
|
||||
self._r = random.Random(seed)
|
||||
|
||||
def shuffle(self, seq):
|
||||
self._r.shuffle(seq)
|
||||
|
||||
def random(self):
|
||||
return self._r.random()
|
||||
|
||||
def choice(self, seq):
|
||||
return self._r.choice(seq)
|
||||
|
||||
|
||||
def test_select_mannerisms_returns_subset_of_pool():
|
||||
persona = _persona()
|
||||
picks = select_mannerisms(persona, rng=_SeededRng(0), n=2)
|
||||
assert len(picks) == 2
|
||||
assert all(m in persona.mannerisms for m in picks)
|
||||
|
||||
|
||||
def test_select_mannerisms_deterministic_under_same_seed():
|
||||
persona = _persona()
|
||||
a = select_mannerisms(persona, rng=_SeededRng(42), n=2)
|
||||
b = select_mannerisms(persona, rng=_SeededRng(42), n=2)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_select_mannerisms_returns_all_when_pool_smaller_than_n():
|
||||
persona = _persona(mannerisms=["a"])
|
||||
picks = select_mannerisms(persona, rng=_SeededRng(0), n=2)
|
||||
assert picks == ["a"]
|
||||
|
||||
|
||||
def test_select_mannerisms_empty_pool():
|
||||
persona = _persona(mannerisms=[])
|
||||
assert select_mannerisms(persona) == []
|
||||
|
||||
|
||||
def test_build_includes_language_constraint_english():
|
||||
sender = _persona(language="en")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "in English" in prompt
|
||||
|
||||
|
||||
def test_build_includes_language_constraint_spanish():
|
||||
sender = _persona(language="es")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "in Spanish" in prompt
|
||||
|
||||
|
||||
def test_build_em_dash_suppression_default():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Do NOT use em-dashes" in prompt
|
||||
|
||||
|
||||
def test_build_em_dash_lifted_for_llm_heavy_persona():
|
||||
sender = _persona(uses_llms_heavily=True)
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Do NOT use em-dashes" not in prompt
|
||||
assert "fine" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_reply_thread_block_prefixes_re():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(
|
||||
sender=sender,
|
||||
recipient=recip,
|
||||
context_hint="budget",
|
||||
parent_subject="Re: Q3 budget",
|
||||
parent_excerpt="Numbers attached.",
|
||||
),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "REPLY in an ongoing thread" in prompt
|
||||
assert "Re: Q3 budget" in prompt
|
||||
assert "Numbers attached" in prompt
|
||||
assert "prefixed with 'Re: '" in prompt
|
||||
|
||||
|
||||
def test_build_returns_mannerisms_used_metadata():
|
||||
sender = _persona()
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
_, used = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(7),
|
||||
)
|
||||
assert used
|
||||
assert all(m in sender.mannerisms for m in used)
|
||||
|
||||
|
||||
def test_build_uses_explicit_signature_when_provided():
|
||||
sender = _persona(signature="-- John\\nCOO")
|
||||
recip = _persona(name="Sarah", email="sarah@corp.com", role="PM")
|
||||
prompt, _ = build(
|
||||
PromptInputs(sender=sender, recipient=recip, context_hint="budget"),
|
||||
rng=_SeededRng(0),
|
||||
)
|
||||
assert "Use this exact signature block" in prompt
|
||||
129
tests/orchestrator/emailgen/test_repo.py
Normal file
129
tests/orchestrator/emailgen/test_repo.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""record / list / count / prune orchestrator_emails on a real SQLite repo."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
await r.engine.dispose()
|
||||
|
||||
|
||||
def _row(
|
||||
mail="d1",
|
||||
thread="thr1",
|
||||
msg="<m1@x>",
|
||||
sender="john@corp.com",
|
||||
recipient="sarah@corp.com",
|
||||
subject="Q3 budget",
|
||||
success=True,
|
||||
in_reply_to=None,
|
||||
ts=None,
|
||||
):
|
||||
return {
|
||||
"ts": ts or datetime.now(timezone.utc),
|
||||
"mail_decky_uuid": mail,
|
||||
"thread_id": thread,
|
||||
"message_id": msg,
|
||||
"in_reply_to": in_reply_to,
|
||||
"sender_email": sender,
|
||||
"recipient_email": recipient,
|
||||
"subject": subject,
|
||||
"language": "en",
|
||||
"eml_path": f"/var/spool/decnet-emails/{thread}/{msg}.eml",
|
||||
"success": success,
|
||||
"payload": {"model": "llama3.1"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_returns_uuid_and_serialises_payload(repo):
|
||||
uuid = await repo.record_orchestrator_email(_row())
|
||||
assert isinstance(uuid, str) and len(uuid) == 36
|
||||
rows = await repo.list_orchestrator_emails()
|
||||
assert len(rows) == 1
|
||||
# payload is stored as JSON text, list endpoint hands it back as the
|
||||
# raw column value — we just verify it round-trips intact.
|
||||
assert json.loads(rows[0]["payload"])["model"] == "llama3.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_filters_by_thread_and_mail_decky(repo):
|
||||
await repo.record_orchestrator_email(_row(thread="t1", msg="<a@x>"))
|
||||
await repo.record_orchestrator_email(_row(thread="t2", msg="<b@x>"))
|
||||
await repo.record_orchestrator_email(_row(mail="d2", msg="<c@x>"))
|
||||
|
||||
by_thread = await repo.list_orchestrator_emails(thread_id="t1")
|
||||
assert {r["message_id"] for r in by_thread} == {"<a@x>"}
|
||||
|
||||
by_mail = await repo.list_orchestrator_emails(mail_decky_uuid="d1")
|
||||
assert len(by_mail) == 2
|
||||
|
||||
everything = await repo.list_orchestrator_emails()
|
||||
assert len(everything) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_orchestrator_emails(repo):
|
||||
for i in range(3):
|
||||
await repo.record_orchestrator_email(_row(msg=f"<m{i}@x>"))
|
||||
assert await repo.count_orchestrator_emails() == 3
|
||||
assert await repo.count_orchestrator_emails(mail_decky_uuid="d1") == 3
|
||||
assert await repo.count_orchestrator_emails(mail_decky_uuid="other") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_lookup_only_returns_pair_threads(repo):
|
||||
await repo.record_orchestrator_email(
|
||||
_row(sender="john@corp.com", recipient="sarah@corp.com", msg="<a@x>")
|
||||
)
|
||||
# Reverse direction (Sarah → John) should still match the same pair.
|
||||
await repo.record_orchestrator_email(
|
||||
_row(sender="sarah@corp.com", recipient="john@corp.com", msg="<b@x>")
|
||||
)
|
||||
# Unrelated pair must not match.
|
||||
await repo.record_orchestrator_email(
|
||||
_row(sender="mike@corp.com", recipient="sarah@corp.com", msg="<c@x>")
|
||||
)
|
||||
threads = await repo.list_orchestrator_email_threads(
|
||||
"d1", "john@corp.com", "sarah@corp.com",
|
||||
)
|
||||
assert {t["message_id"] for t in threads} == {"<a@x>", "<b@x>"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_lookup_excludes_failed_rows(repo):
|
||||
await repo.record_orchestrator_email(_row(msg="<ok@x>", success=True))
|
||||
await repo.record_orchestrator_email(_row(msg="<bad@x>", success=False))
|
||||
threads = await repo.list_orchestrator_email_threads(
|
||||
"d1", "john@corp.com", "sarah@corp.com",
|
||||
)
|
||||
assert {t["message_id"] for t in threads} == {"<ok@x>"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prune_caps_per_decky(repo):
|
||||
# Insert 5 rows on d1 with strictly-increasing timestamps so the
|
||||
# prune's "newest-first keep, drop the rest" deterministically picks
|
||||
# the older two.
|
||||
base = datetime.now(timezone.utc) - timedelta(hours=10)
|
||||
for i in range(5):
|
||||
await repo.record_orchestrator_email(
|
||||
_row(msg=f"<m{i}@x>", ts=base + timedelta(minutes=i))
|
||||
)
|
||||
# Cap at 3 — expect 2 deleted.
|
||||
deleted = await repo.prune_orchestrator_emails(per_decky_cap=3)
|
||||
assert deleted == 2
|
||||
remaining = await repo.list_orchestrator_emails()
|
||||
assert len(remaining) == 3
|
||||
# The three newest survived.
|
||||
assert {r["message_id"] for r in remaining} == {"<m2@x>", "<m3@x>", "<m4@x>"}
|
||||
161
tests/orchestrator/emailgen/test_scheduler.py
Normal file
161
tests/orchestrator/emailgen/test_scheduler.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Scheduler.pick() — async, takes a repo-shaped object."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.orchestrator.emailgen import scheduler
|
||||
|
||||
|
||||
_PERSONAS_TWO = [
|
||||
{
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
},
|
||||
{
|
||||
"name": "Sarah Johnson",
|
||||
"email": "sarah@corp.com",
|
||||
"role": "PM",
|
||||
"tone": "direct",
|
||||
"mannerisms": ["uses bullets"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
"""Minimal repo stub matching the methods scheduler.pick() uses."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
deckies: list[dict[str, Any]] | None = None,
|
||||
topologies: dict[str, dict[str, Any]] | None = None,
|
||||
threads: list[dict[str, Any]] | None = None,
|
||||
):
|
||||
self.deckies = deckies or []
|
||||
self.topologies = topologies or {}
|
||||
self.threads = threads or []
|
||||
self.thread_calls = 0
|
||||
|
||||
async def list_running_topology_deckies(self):
|
||||
return self.deckies
|
||||
|
||||
async def get_topology(self, topology_id: str):
|
||||
return self.topologies.get(topology_id)
|
||||
|
||||
async def list_orchestrator_email_threads(self, *args, **kwargs):
|
||||
self.thread_calls += 1
|
||||
return list(self.threads)
|
||||
|
||||
|
||||
def _decky(uuid="d1", name="mailhost", services=("imap",), topology_id="t1"):
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"services": list(services),
|
||||
"topology_id": topology_id,
|
||||
}
|
||||
|
||||
|
||||
def _topology(personas=_PERSONAS_TWO, language_default="en"):
|
||||
return {
|
||||
"id": "t1",
|
||||
"email_personas": json.dumps(personas),
|
||||
"language_default": language_default,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_no_mail_decky_returns_none():
|
||||
repo = _FakeRepo(deckies=[_decky(services=("ssh",))])
|
||||
assert await scheduler.pick(repo) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_unknown_topology_returns_none():
|
||||
repo = _FakeRepo(deckies=[_decky()])
|
||||
# No topology row for "t1" — scheduler should bail.
|
||||
assert await scheduler.pick(repo) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_topology_with_one_persona_returns_none():
|
||||
repo = _FakeRepo(
|
||||
deckies=[_decky()],
|
||||
topologies={"t1": _topology(personas=_PERSONAS_TWO[:1])},
|
||||
)
|
||||
assert await scheduler.pick(repo) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_returns_action_for_valid_setup():
|
||||
repo = _FakeRepo(
|
||||
deckies=[_decky()],
|
||||
topologies={"t1": _topology()},
|
||||
)
|
||||
action = await scheduler.pick(repo, now=datetime(2026, 4, 26, 12, 0, 0))
|
||||
assert action is not None
|
||||
assert action.mail_decky_uuid == "d1"
|
||||
assert action.sender.email != action.recipient.email
|
||||
assert action.thread_id # populated for both new and reply branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_active_hours_filter_kicks_in_at_midnight():
|
||||
repo = _FakeRepo(
|
||||
deckies=[_decky()],
|
||||
topologies={"t1": _topology()},
|
||||
)
|
||||
# Default active_hours is 09:00-18:00; midnight => everyone out of office.
|
||||
action = await scheduler.pick(repo, now=datetime(2026, 4, 26, 3, 0, 0))
|
||||
assert action is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_uses_pop3_decky_too():
|
||||
repo = _FakeRepo(
|
||||
deckies=[_decky(services=("pop3",))],
|
||||
topologies={"t1": _topology()},
|
||||
)
|
||||
action = await scheduler.pick(repo, now=datetime(2026, 4, 26, 12, 0, 0))
|
||||
assert action is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_reply_chain_sets_in_reply_to():
|
||||
threads = [{
|
||||
"thread_id": "thr1",
|
||||
"message_id": "<old@corp.com>",
|
||||
"subject": "Q3 budget",
|
||||
}]
|
||||
repo = _FakeRepo(
|
||||
deckies=[_decky()],
|
||||
topologies={"t1": _topology()},
|
||||
threads=threads,
|
||||
)
|
||||
|
||||
# Force the "reply" branch by stubbing the RNG: random() < 0.6 is True.
|
||||
class _Rng:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def choice(self, seq):
|
||||
return seq[0]
|
||||
|
||||
def random(self):
|
||||
return 0.0 # always reply
|
||||
|
||||
action = await scheduler.pick(
|
||||
repo, rand=_Rng(), now=datetime(2026, 4, 26, 12, 0, 0),
|
||||
)
|
||||
assert action is not None
|
||||
assert action.is_reply is True
|
||||
assert action.parent_message_id == "<old@corp.com>"
|
||||
assert action.thread_id == "thr1"
|
||||
assert action.subject_hint == "Re: Q3 budget"
|
||||
61
tests/orchestrator/emailgen/test_threads.py
Normal file
61
tests/orchestrator/emailgen/test_threads.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Thread-chain helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decnet.orchestrator.emailgen.threads import (
|
||||
ThreadChain,
|
||||
new_message_id,
|
||||
new_thread_id,
|
||||
references_for_reply,
|
||||
reply_subject,
|
||||
)
|
||||
|
||||
|
||||
def test_new_thread_id_is_uuid_string():
|
||||
tid = new_thread_id()
|
||||
assert len(tid) == 36
|
||||
assert tid.count("-") == 4
|
||||
|
||||
|
||||
def test_new_message_id_format_with_domain():
|
||||
mid = new_message_id("example.com")
|
||||
assert mid.startswith("<") and mid.endswith(">")
|
||||
assert "@example.com" in mid
|
||||
|
||||
|
||||
def test_new_message_id_handles_blank_domain():
|
||||
mid = new_message_id(" ")
|
||||
assert "@localhost" in mid
|
||||
|
||||
|
||||
def test_reply_subject_prepends_re():
|
||||
assert reply_subject("Q3 budget") == "Re: Q3 budget"
|
||||
|
||||
|
||||
def test_reply_subject_collapses_existing_re():
|
||||
assert reply_subject("Re: Re: Q3 budget") == "Re: Q3 budget"
|
||||
assert reply_subject("RE: Q3 budget") == "Re: Q3 budget"
|
||||
|
||||
|
||||
def test_references_for_reply_root_is_empty():
|
||||
assert references_for_reply(None) == ""
|
||||
|
||||
|
||||
def test_references_for_reply_appends_parent():
|
||||
chain = ThreadChain(
|
||||
thread_id="t1",
|
||||
parent_message_id="<m2@x>",
|
||||
references=("<m1@x>",),
|
||||
parent_subject="Re: budget",
|
||||
)
|
||||
refs = references_for_reply(chain)
|
||||
assert refs == "<m1@x> <m2@x>"
|
||||
|
||||
|
||||
def test_references_empty_chain_starts_with_parent_only():
|
||||
chain = ThreadChain(
|
||||
thread_id="t1",
|
||||
parent_message_id="<m1@x>",
|
||||
references=(),
|
||||
parent_subject="budget",
|
||||
)
|
||||
assert references_for_reply(chain) == "<m1@x>"
|
||||
136
tests/orchestrator/emailgen/test_worker_integration.py
Normal file
136
tests/orchestrator/emailgen/test_worker_integration.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""End-to-end-ish: one emailgen tick against a real SQLite repo + FakeBus,
|
||||
with the Ollama + docker-exec subprocess stubbed."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.orchestrator.drivers import email as email_driver
|
||||
from decnet.orchestrator.emailgen import worker as eg_worker
|
||||
from decnet.orchestrator.emailgen.scheduler import EmailAction # noqa: F401
|
||||
from decnet.web.db.models import Topology, TopologyDecky
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
_PERSONAS = [
|
||||
{
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
"active_hours": "00:00-00:00", # always-on so test is hour-independent
|
||||
},
|
||||
{
|
||||
"name": "Sarah Johnson",
|
||||
"email": "sarah@corp.com",
|
||||
"role": "PM",
|
||||
"tone": "direct",
|
||||
"mannerisms": ["uses bullets"],
|
||||
"active_hours": "00:00-00:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
await r.engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus():
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
try:
|
||||
yield bus
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
async def _seed_mail_topology(repo: SQLiteRepository) -> str:
|
||||
async with repo._session() as session:
|
||||
topo = Topology(
|
||||
name="t-mail",
|
||||
config_snapshot="{}",
|
||||
status="active",
|
||||
email_personas=json.dumps(_PERSONAS),
|
||||
language_default="en",
|
||||
)
|
||||
session.add(topo)
|
||||
await session.commit()
|
||||
await session.refresh(topo)
|
||||
decky = TopologyDecky(
|
||||
topology_id=topo.id,
|
||||
name="mailhost",
|
||||
services=json.dumps(["imap"]),
|
||||
ip="10.0.0.10",
|
||||
state="running",
|
||||
)
|
||||
session.add(decky)
|
||||
await session.commit()
|
||||
await session.refresh(decky)
|
||||
return decky.uuid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_tick_records_and_publishes(repo, fake_bus, monkeypatch):
|
||||
decky_uuid = await _seed_mail_topology(repo)
|
||||
|
||||
async def fake_run_capture(argv, *, stdin_data=None, timeout=8.0):
|
||||
if argv[0] == "ollama":
|
||||
return 0, "Subject: Hi\n\nBody here.\n", ""
|
||||
return 0, "", ""
|
||||
|
||||
monkeypatch.setattr(email_driver, "_run_capture", fake_run_capture)
|
||||
|
||||
received: list = []
|
||||
|
||||
async def collect():
|
||||
async with fake_bus.subscribe(f"orchestrator.email.{decky_uuid}") as sub:
|
||||
async for ev in sub:
|
||||
received.append(ev)
|
||||
return
|
||||
|
||||
import asyncio
|
||||
collector = asyncio.create_task(collect())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
driver = email_driver.EmailDriver()
|
||||
await eg_worker._one_tick(repo, driver, fake_bus)
|
||||
await asyncio.wait_for(collector, timeout=2.0)
|
||||
|
||||
rows = await repo.list_orchestrator_emails()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["success"] is True
|
||||
assert row["mail_decky_uuid"] == decky_uuid
|
||||
assert row["subject"] == "Hi"
|
||||
assert row["language"] == "en"
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].topic == f"orchestrator.email.{decky_uuid}"
|
||||
assert received[0].payload["kind"] == "email"
|
||||
assert received[0].payload["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_tick_noop_when_no_mail_decky(repo, fake_bus, monkeypatch):
|
||||
called = False
|
||||
|
||||
async def fake_run_capture(argv, *, stdin_data=None, timeout=8.0):
|
||||
nonlocal called
|
||||
called = True
|
||||
return 0, "Subject: x\n\nb\n", ""
|
||||
|
||||
monkeypatch.setattr(email_driver, "_run_capture", fake_run_capture)
|
||||
|
||||
driver = email_driver.EmailDriver()
|
||||
await eg_worker._one_tick(repo, driver, fake_bus)
|
||||
assert called is False
|
||||
assert await repo.list_orchestrator_emails() == []
|
||||
Reference in New Issue
Block a user