human hand off
This commit is contained in:
parent
d0fee5c8f7
commit
7af776196a
34 changed files with 2941 additions and 195 deletions
423
backend/tests/test_handoff_runtime.py
Normal file
423
backend/tests/test_handoff_runtime.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from bson import ObjectId
|
||||
|
||||
from app.services.handoff_delivery import enqueue_handoff_delivery
|
||||
from app.services.handoff_mirror_policy import (
|
||||
GENERATED_BY_AGENT,
|
||||
GENERATED_BY_HANDOFF_BRIDGE,
|
||||
MESSAGE_ORIGIN_AGENT,
|
||||
MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
RELAY_STATUS_BLOCKED,
|
||||
should_mirror_to_human,
|
||||
)
|
||||
from app.services import human_handoff_service
|
||||
from app.services.human_handoff_service import (
|
||||
MESSAGE_SCOPE_EXTERNAL,
|
||||
MESSAGE_SCOPE_HANDOFF,
|
||||
build_handoff_subject,
|
||||
maybe_trigger_handoff,
|
||||
process_human_reply,
|
||||
resolve_handoff_from_inbound,
|
||||
)
|
||||
from app.services.mailbox_scope import apply_external_scope
|
||||
from app.services.external_reply_service import build_external_reply_plan
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, items):
|
||||
self.items = items
|
||||
|
||||
def sort(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
async def to_list(self, length=None):
|
||||
return list(self.items)[:length]
|
||||
|
||||
|
||||
class FakeCollection:
|
||||
def __init__(self, items=None):
|
||||
self.items = list(items or [])
|
||||
self.inserted = []
|
||||
self.updated = []
|
||||
|
||||
async def find_one(self, query, projection=None):
|
||||
for item in self.items:
|
||||
if _matches(item, query):
|
||||
return item
|
||||
return None
|
||||
|
||||
def find(self, query=None):
|
||||
query = query or {}
|
||||
return FakeCursor([item for item in self.items if _matches(item, query)])
|
||||
|
||||
async def insert_one(self, document):
|
||||
document = dict(document)
|
||||
document.setdefault("_id", ObjectId())
|
||||
self.items.append(document)
|
||||
self.inserted.append(document)
|
||||
return SimpleNamespace(inserted_id=document["_id"])
|
||||
|
||||
async def update_one(self, query, update, upsert=False):
|
||||
self.updated.append((query, update, upsert))
|
||||
existing = await self.find_one(query)
|
||||
if existing is None and upsert:
|
||||
inserted = {"_id": ObjectId(), **query, **update.get("$setOnInsert", {})}
|
||||
self.items.append(inserted)
|
||||
return SimpleNamespace(upserted_id=inserted["_id"], modified_count=0)
|
||||
if existing is not None:
|
||||
existing.update(update.get("$set", {}))
|
||||
return SimpleNamespace(upserted_id=None, modified_count=1)
|
||||
return SimpleNamespace(upserted_id=None, modified_count=0)
|
||||
|
||||
async def find_one_and_update(self, query, update, **kwargs):
|
||||
existing = await self.find_one(query)
|
||||
if existing is None:
|
||||
return None
|
||||
existing.update(update.get("$set", {}))
|
||||
return existing
|
||||
|
||||
|
||||
def _matches(document, query):
|
||||
for key, expected in query.items():
|
||||
if key == "$or":
|
||||
if not any(_matches(document, clause) for clause in expected):
|
||||
return False
|
||||
continue
|
||||
if key == "$ne":
|
||||
continue
|
||||
actual = document.get(key)
|
||||
if isinstance(expected, dict):
|
||||
if "$ne" in expected and actual == expected["$ne"]:
|
||||
return False
|
||||
if "$in" in expected and actual not in expected["$in"]:
|
||||
return False
|
||||
continue
|
||||
if actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_external_mailbox_filter_excludes_handoff_scope():
|
||||
match = {"account_id": "account-1", "mailbox": "inbox"}
|
||||
apply_external_scope(match)
|
||||
assert match["$or"] == [
|
||||
{"message_scope": "EXTERNAL"},
|
||||
{"message_scope": {"$exists": False}},
|
||||
]
|
||||
|
||||
|
||||
def test_bridge_plan_never_references_private_ids():
|
||||
customer = {
|
||||
"_id": ObjectId(),
|
||||
"internet_message_id": "<A@gmail.com>",
|
||||
"message_id": "<A@gmail.com>",
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"references": [],
|
||||
"subject": "Invoice 42",
|
||||
}
|
||||
private_id = "<H1@gentic.email>"
|
||||
plan = build_external_reply_plan(
|
||||
parent_external_message=customer,
|
||||
body_text="Approved",
|
||||
recipients=["customer@gmail.com"],
|
||||
)
|
||||
assert plan.threading.in_reply_to == "<A@gmail.com>"
|
||||
assert private_id not in plan.threading.references
|
||||
assert plan.subject == "Re: Invoice 42"
|
||||
assert "handoff" not in plan.subject.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_handoff_is_sticky_without_reevaluating_rules():
|
||||
external_thread_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
handoff = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"external_thread_id": external_thread_id,
|
||||
"status": "ACTIVE",
|
||||
"rule_name": "Finance approvals",
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection([handoff]),
|
||||
human_handoff_rules=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"name": "Finance approvals",
|
||||
"priority": 1,
|
||||
"labels": ["Finance"],
|
||||
"email_types": ["transactional"],
|
||||
"instructions": "only invoices",
|
||||
"active": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
]
|
||||
),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
)
|
||||
message = {
|
||||
"_id": source_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "inbound",
|
||||
"sender": {"address": "customer@example.com"},
|
||||
"subject": "Hello there",
|
||||
"body_text": "just checking in",
|
||||
"snippet": "just checking in",
|
||||
}
|
||||
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message=message,
|
||||
external_thread_id=external_thread_id,
|
||||
)
|
||||
|
||||
assert result["_id"] == handoff["_id"]
|
||||
assert database.handoff_delivery_jobs.items
|
||||
assert (
|
||||
database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_delivery_jobs_share_an_idempotency_key():
|
||||
database = SimpleNamespace(handoff_delivery_jobs=FakeCollection())
|
||||
handoff_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
first = await enqueue_handoff_delivery(
|
||||
database,
|
||||
kind="MIRROR_TO_HUMAN",
|
||||
account_id="account-1",
|
||||
handoff_id=handoff_id,
|
||||
source_message_id=source_id,
|
||||
agent_email="agent@gentic.email",
|
||||
)
|
||||
second = await enqueue_handoff_delivery(
|
||||
database,
|
||||
kind="MIRROR_TO_HUMAN",
|
||||
account_id="account-1",
|
||||
handoff_id=handoff_id,
|
||||
source_message_id=source_id,
|
||||
agent_email="agent@gentic.email",
|
||||
)
|
||||
assert first["idempotency_key"] == second["idempotency_key"]
|
||||
assert len(database.handoff_delivery_jobs.items) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_human_reply_resolves_only_to_private_handoff():
|
||||
parent_id = ObjectId()
|
||||
thread_id = ObjectId()
|
||||
handoff = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"handoff_thread_id": thread_id,
|
||||
"human_email": "owner@example.com",
|
||||
"agent_email": "agent@gentic.email",
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
messages=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": parent_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"message_id": "<H1@gentic.email>",
|
||||
"message_id_folded": "<h1@gentic.email>",
|
||||
}
|
||||
]
|
||||
),
|
||||
mailbox_entries=FakeCollection(
|
||||
[
|
||||
{
|
||||
"account_id": "account-1",
|
||||
"message_id": parent_id,
|
||||
"message_scope": MESSAGE_SCOPE_HANDOFF,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
]
|
||||
),
|
||||
human_handoffs=FakeCollection([handoff]),
|
||||
)
|
||||
|
||||
resolved = await resolve_handoff_from_inbound(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
sender_address="owner@example.com",
|
||||
in_reply_to="<H1@gentic.email>",
|
||||
references=["<H1@gentic.email>"],
|
||||
)
|
||||
assert resolved["_id"] == handoff["_id"]
|
||||
|
||||
|
||||
def test_human_echo_and_agent_mirror_policy():
|
||||
assert should_mirror_to_human(
|
||||
{
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
"generated_from_handoff_message_id": ObjectId(),
|
||||
}
|
||||
) is False
|
||||
assert should_mirror_to_human(
|
||||
{
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_AGENT,
|
||||
"message_origin": MESSAGE_ORIGIN_AGENT,
|
||||
}
|
||||
) is True
|
||||
|
||||
|
||||
def test_private_subject_stays_off_the_external_plane():
|
||||
private = build_handoff_subject(
|
||||
rule_name="Finance approvals",
|
||||
external_sender="customer@example.com",
|
||||
external_subject="Invoice 42",
|
||||
)
|
||||
assert private.startswith("Finance approvals · handoff ·")
|
||||
assert "Invoice 42" in private
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_matching_rule_creates_a_new_linked_handoff():
|
||||
external_thread_id = ObjectId()
|
||||
source_id = ObjectId()
|
||||
rule = {
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"name": "Finance approvals",
|
||||
"priority": 1,
|
||||
"owner_email": "owner@example.com",
|
||||
"owner_name": "Owner",
|
||||
"labels": ["All mail"],
|
||||
"email_types": ["all"],
|
||||
"instructions": "",
|
||||
"active": True,
|
||||
"allow_replies": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection(),
|
||||
human_handoff_rules=FakeCollection([rule]),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
email_threads=FakeCollection(),
|
||||
)
|
||||
message = {
|
||||
"_id": source_id,
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "inbound",
|
||||
"sender": {"address": "customer@example.com"},
|
||||
"subject": "Invoice 42",
|
||||
"body_text": "please review this invoice",
|
||||
"snippet": "please review this invoice",
|
||||
}
|
||||
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message=message,
|
||||
external_thread_id=external_thread_id,
|
||||
)
|
||||
|
||||
assert result["rule_name"] == "Finance approvals"
|
||||
assert result["external_thread_id"] == external_thread_id
|
||||
assert result["handoff_thread_id"]
|
||||
assert result["private_subject"].startswith("Finance approvals · handoff ·")
|
||||
assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_trigger_does_not_echo_human_origin_messages():
|
||||
database = SimpleNamespace(
|
||||
human_handoffs=FakeCollection(
|
||||
[
|
||||
{
|
||||
"_id": ObjectId(),
|
||||
"account_id": "account-1",
|
||||
"external_thread_id": ObjectId(),
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
]
|
||||
),
|
||||
handoff_delivery_jobs=FakeCollection(),
|
||||
)
|
||||
result = await maybe_trigger_handoff(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
external_message={
|
||||
"_id": ObjectId(),
|
||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||
"direction": "outbound",
|
||||
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
|
||||
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
|
||||
"generated_from_handoff_message_id": ObjectId(),
|
||||
"sender": {"address": "agent@gentic.email"},
|
||||
},
|
||||
external_thread_id=database.human_handoffs.items[0]["external_thread_id"],
|
||||
)
|
||||
assert result is None
|
||||
assert database.handoff_delivery_jobs.items == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaked_private_subject_blocks_human_relay(monkeypatch):
|
||||
stored = {}
|
||||
|
||||
async def store(_database, **kwargs):
|
||||
stored.update(kwargs)
|
||||
return {"_id": ObjectId(), **kwargs}
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def enqueue(*args, **kwargs):
|
||||
enqueued.append(kwargs)
|
||||
return {}
|
||||
|
||||
class BrokenStorage:
|
||||
async def get(self, *_args, **_kwargs):
|
||||
raise RuntimeError("raw mime unavailable")
|
||||
|
||||
monkeypatch.setattr(human_handoff_service, "store_handoff_message", store)
|
||||
monkeypatch.setattr(human_handoff_service, "enqueue_handoff_delivery", enqueue)
|
||||
monkeypatch.setattr(human_handoff_service, "get_raw_mime_storage", lambda: BrokenStorage())
|
||||
|
||||
database = SimpleNamespace(messages=FakeCollection(), human_handoffs=FakeCollection())
|
||||
await process_human_reply(
|
||||
database,
|
||||
account_id="account-1",
|
||||
agent_email="agent@gentic.email",
|
||||
handoff={
|
||||
"_id": ObjectId(),
|
||||
"handoff_thread_id": ObjectId(),
|
||||
"allow_replies": True,
|
||||
},
|
||||
parsed={
|
||||
"sender": {"name": "Owner", "address": "owner@example.com"},
|
||||
"to": [{"address": "agent@gentic.email"}],
|
||||
"subject": "Re: Finance approvals · handoff · customer@example.com · Invoice 42",
|
||||
"message_id": "<H2@gmail.com>",
|
||||
"in_reply_to": "<H1@gentic.email>",
|
||||
"references": ["<H1@gentic.email>"],
|
||||
"attachments": [],
|
||||
"raw_headers": {},
|
||||
},
|
||||
body_text=(
|
||||
"Finance approvals · handoff · customer@example.com · Invoice 42\n"
|
||||
"Please approve"
|
||||
),
|
||||
body_html=None,
|
||||
raw_mime={"bucket": "mail", "name": "raw.eml"},
|
||||
delivery_key="delivery-1",
|
||||
)
|
||||
assert stored["relay_status"] == RELAY_STATUS_BLOCKED
|
||||
assert enqueued == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue