Spaces:
Sleeping
Sleeping
Phase 2: BaseTool + MailAPI with 3 drift handlers
Browse files- tests/test_mail.py +149 -0
- tools/base.py +59 -3
- tools/mail.py +120 -3
tests/test_mail.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MailAPI + BaseTool acceptance tests — Phase 2."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from models import DriftEvent
|
| 5 |
+
from tools.mail import MailAPI
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _fresh_mail() -> MailAPI:
|
| 9 |
+
seed = {
|
| 10 |
+
"messages": [
|
| 11 |
+
{"id": "msg_1", "from": "a@x.com", "to": "u@org.com",
|
| 12 |
+
"subject": "hi", "body": "hello", "folder": "inbox"},
|
| 13 |
+
{"id": "msg_2", "from": "b@x.com", "to": "u@org.com",
|
| 14 |
+
"subject": "welcome", "body": "hi there", "folder": "inbox"},
|
| 15 |
+
{"id": "msg_3", "from": "c@x.com", "to": "u@org.com",
|
| 16 |
+
"subject": "fyi", "body": "fyi", "folder": "inbox"},
|
| 17 |
+
]
|
| 18 |
+
}
|
| 19 |
+
return MailAPI(seed_data=seed)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_baseline_list_messages() -> None:
|
| 23 |
+
mail = _fresh_mail()
|
| 24 |
+
resp = mail.call("list_messages", {"folder": "inbox"})
|
| 25 |
+
assert resp.ok is True
|
| 26 |
+
assert resp.status == 200
|
| 27 |
+
assert resp.body is not None
|
| 28 |
+
assert "messages" in resp.body
|
| 29 |
+
assert "next_page_token" in resp.body
|
| 30 |
+
assert len(resp.body["messages"]) == 3
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_baseline_send_message() -> None:
|
| 34 |
+
mail = _fresh_mail()
|
| 35 |
+
resp = mail.call(
|
| 36 |
+
"send_message",
|
| 37 |
+
{"to": "x@y.com", "subject": "Hello", "body": "Hi!"},
|
| 38 |
+
)
|
| 39 |
+
assert resp.ok is True
|
| 40 |
+
assert resp.status == 200
|
| 41 |
+
assert resp.body is not None
|
| 42 |
+
assert "message_id" in resp.body
|
| 43 |
+
assert "sent_at" in resp.body
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_missing_required_param() -> None:
|
| 47 |
+
mail = _fresh_mail()
|
| 48 |
+
resp = mail.call(
|
| 49 |
+
"send_message",
|
| 50 |
+
{"to": "x@y.com", "subject": "Hello"}, # body missing
|
| 51 |
+
)
|
| 52 |
+
assert resp.ok is False
|
| 53 |
+
assert resp.status == 400
|
| 54 |
+
assert resp.error is not None
|
| 55 |
+
assert "missing" in resp.error.lower()
|
| 56 |
+
assert "body" in resp.error
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_drift_field_rename_list_messages() -> None:
|
| 60 |
+
mail = _fresh_mail()
|
| 61 |
+
event = DriftEvent(
|
| 62 |
+
tool="mail",
|
| 63 |
+
endpoint="list_messages",
|
| 64 |
+
kind="field_rename",
|
| 65 |
+
fires_at_step=1,
|
| 66 |
+
details={"from": "messages", "to": "items"},
|
| 67 |
+
)
|
| 68 |
+
mail.apply_drift(event)
|
| 69 |
+
resp = mail.call("list_messages", {"folder": "inbox"})
|
| 70 |
+
assert resp.ok is True
|
| 71 |
+
assert resp.body is not None
|
| 72 |
+
assert "items" in resp.body
|
| 73 |
+
assert "next_cursor" in resp.body
|
| 74 |
+
assert "messages" not in resp.body
|
| 75 |
+
assert "next_page_token" not in resp.body
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_drift_endpoint_deprecation() -> None:
|
| 79 |
+
mail = _fresh_mail()
|
| 80 |
+
event = DriftEvent(
|
| 81 |
+
tool="mail",
|
| 82 |
+
endpoint="send_message",
|
| 83 |
+
kind="endpoint_deprecation",
|
| 84 |
+
fires_at_step=1,
|
| 85 |
+
details={"replacement": "messages.send"},
|
| 86 |
+
)
|
| 87 |
+
mail.apply_drift(event)
|
| 88 |
+
|
| 89 |
+
old = mail.call(
|
| 90 |
+
"send_message",
|
| 91 |
+
{"to": "x@y.com", "subject": "Hi", "body": "Hi"},
|
| 92 |
+
)
|
| 93 |
+
assert old.ok is False
|
| 94 |
+
assert old.status == 410
|
| 95 |
+
|
| 96 |
+
new = mail.call(
|
| 97 |
+
"messages.send",
|
| 98 |
+
{"to": "x@y.com", "subject": "Hi", "body": "Hi"},
|
| 99 |
+
)
|
| 100 |
+
assert new.ok is True
|
| 101 |
+
assert new.status == 200
|
| 102 |
+
assert new.body is not None
|
| 103 |
+
assert "message_id" in new.body
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_drift_new_required_param() -> None:
|
| 107 |
+
mail = _fresh_mail()
|
| 108 |
+
event = DriftEvent(
|
| 109 |
+
tool="mail",
|
| 110 |
+
endpoint="send_message",
|
| 111 |
+
kind="new_required_param",
|
| 112 |
+
fires_at_step=1,
|
| 113 |
+
details={"param": "idempotency_key"},
|
| 114 |
+
)
|
| 115 |
+
mail.apply_drift(event)
|
| 116 |
+
|
| 117 |
+
without_key = mail.call(
|
| 118 |
+
"send_message",
|
| 119 |
+
{"to": "x@y.com", "subject": "Hi", "body": "Hi"},
|
| 120 |
+
)
|
| 121 |
+
assert without_key.ok is False
|
| 122 |
+
assert without_key.status == 400
|
| 123 |
+
assert without_key.error is not None
|
| 124 |
+
assert "idempotency_key" in without_key.error
|
| 125 |
+
|
| 126 |
+
with_key = mail.call(
|
| 127 |
+
"send_message",
|
| 128 |
+
{"to": "x@y.com", "subject": "Hi", "body": "Hi",
|
| 129 |
+
"idempotency_key": "k1"},
|
| 130 |
+
)
|
| 131 |
+
assert with_key.ok is True
|
| 132 |
+
assert with_key.status == 200
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_get_message_roundtrip() -> None:
|
| 136 |
+
mail = _fresh_mail()
|
| 137 |
+
sent = mail.call(
|
| 138 |
+
"send_message",
|
| 139 |
+
{"to": "x@y.com", "subject": "Hi", "body": "Hi there"},
|
| 140 |
+
)
|
| 141 |
+
assert sent.ok is True
|
| 142 |
+
assert sent.body is not None
|
| 143 |
+
mid = sent.body["message_id"]
|
| 144 |
+
|
| 145 |
+
got = mail.call("get_message", {"message_id": mid})
|
| 146 |
+
assert got.ok is True
|
| 147 |
+
assert got.status == 200
|
| 148 |
+
assert got.body is not None
|
| 149 |
+
assert got.body["id"] == mid
|
tools/base.py
CHANGED
|
@@ -1,4 +1,60 @@
|
|
| 1 |
-
"""BaseTool + EndpointSchema — abstract contract for all SaaS tool APIs.
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BaseTool + EndpointSchema — abstract contract for all SaaS tool APIs."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import copy
|
| 5 |
+
from typing import Callable, Optional
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from models import DriftEvent, ToolResponse
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EndpointSchema(BaseModel):
|
| 13 |
+
name: str
|
| 14 |
+
params: dict[str, str]
|
| 15 |
+
required: list[str]
|
| 16 |
+
response_shape: dict[str, str]
|
| 17 |
+
error_codes: dict[int, str]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class BaseTool:
|
| 21 |
+
name: str = ""
|
| 22 |
+
baseline_schemas: dict[str, EndpointSchema] = {}
|
| 23 |
+
|
| 24 |
+
def __init__(self) -> None:
|
| 25 |
+
self.active_schemas: dict[str, EndpointSchema] = copy.deepcopy(self.baseline_schemas)
|
| 26 |
+
self.handlers: dict[str, Callable[[dict], ToolResponse]] = {}
|
| 27 |
+
|
| 28 |
+
def get_schema(self, endpoint: Optional[str] = None) -> dict:
|
| 29 |
+
if endpoint is None:
|
| 30 |
+
return {k: v.model_dump() for k, v in self.active_schemas.items()}
|
| 31 |
+
if endpoint not in self.active_schemas:
|
| 32 |
+
return {}
|
| 33 |
+
return self.active_schemas[endpoint].model_dump()
|
| 34 |
+
|
| 35 |
+
def call(self, endpoint: str, params: dict) -> ToolResponse:
|
| 36 |
+
if endpoint not in self.active_schemas:
|
| 37 |
+
return ToolResponse(
|
| 38 |
+
ok=False,
|
| 39 |
+
status=410,
|
| 40 |
+
error=f"Endpoint '{endpoint}' is no longer available on {self.name}.",
|
| 41 |
+
)
|
| 42 |
+
schema = self.active_schemas[endpoint]
|
| 43 |
+
missing = [p for p in schema.required if p not in params]
|
| 44 |
+
if missing:
|
| 45 |
+
return ToolResponse(
|
| 46 |
+
ok=False,
|
| 47 |
+
status=400,
|
| 48 |
+
error=f"Missing required params: {missing}",
|
| 49 |
+
)
|
| 50 |
+
handler = self.handlers.get(endpoint)
|
| 51 |
+
if handler is None:
|
| 52 |
+
return ToolResponse(
|
| 53 |
+
ok=False,
|
| 54 |
+
status=501,
|
| 55 |
+
error=f"No handler registered for endpoint '{endpoint}' on {self.name}.",
|
| 56 |
+
)
|
| 57 |
+
return handler(params)
|
| 58 |
+
|
| 59 |
+
def apply_drift(self, event: DriftEvent) -> None:
|
| 60 |
+
raise NotImplementedError
|
tools/mail.py
CHANGED
|
@@ -1,4 +1,121 @@
|
|
| 1 |
-
"""MailAPI — list_messages, send_message, get_message + 3 drift handlers.
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MailAPI — list_messages, send_message, get_message + 3 drift handlers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import copy
|
| 5 |
+
|
| 6 |
+
from models import DriftEvent, ToolResponse
|
| 7 |
+
from tools.base import BaseTool, EndpointSchema
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_LIST_MESSAGES_BASE = EndpointSchema(
|
| 11 |
+
name="list_messages",
|
| 12 |
+
params={"folder": "str", "limit": "int", "page_token": "str"},
|
| 13 |
+
required=["folder"],
|
| 14 |
+
response_shape={"messages": "list", "next_page_token": "str"},
|
| 15 |
+
error_codes={200: "ok", 400: "bad_request", 429: "rate_limit"},
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
_SEND_MESSAGE_BASE = EndpointSchema(
|
| 19 |
+
name="send_message",
|
| 20 |
+
params={"to": "str", "subject": "str", "body": "str"},
|
| 21 |
+
required=["to", "subject", "body"],
|
| 22 |
+
response_shape={"message_id": "str", "sent_at": "str"},
|
| 23 |
+
error_codes={200: "ok", 400: "bad_request", 422: "validation"},
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
_GET_MESSAGE_BASE = EndpointSchema(
|
| 27 |
+
name="get_message",
|
| 28 |
+
params={"message_id": "str"},
|
| 29 |
+
required=["message_id"],
|
| 30 |
+
response_shape={"id": "str", "from": "str", "subject": "str", "body": "str"},
|
| 31 |
+
error_codes={200: "ok", 404: "not_found"},
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MailAPI(BaseTool):
|
| 36 |
+
name = "mail"
|
| 37 |
+
baseline_schemas = {
|
| 38 |
+
"list_messages": _LIST_MESSAGES_BASE,
|
| 39 |
+
"send_message": _SEND_MESSAGE_BASE,
|
| 40 |
+
"get_message": _GET_MESSAGE_BASE,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
def __init__(self, seed_data: dict) -> None:
|
| 44 |
+
super().__init__()
|
| 45 |
+
self.mailbox: list[dict] = list(seed_data.get("messages", []))
|
| 46 |
+
self.sent_messages: list[dict] = []
|
| 47 |
+
self.handlers = {
|
| 48 |
+
"list_messages": self._list_messages,
|
| 49 |
+
"send_message": self._send_message,
|
| 50 |
+
"get_message": self._get_message,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
def _list_messages(self, params: dict) -> ToolResponse:
|
| 54 |
+
folder = params["folder"]
|
| 55 |
+
items = [m for m in self.mailbox if m.get("folder") == folder]
|
| 56 |
+
shape_keys = set(self.active_schemas["list_messages"].response_shape.keys())
|
| 57 |
+
if "items" in shape_keys and "next_cursor" in shape_keys:
|
| 58 |
+
return ToolResponse(
|
| 59 |
+
ok=True,
|
| 60 |
+
status=200,
|
| 61 |
+
body={
|
| 62 |
+
"items": items[:10],
|
| 63 |
+
"next_cursor": "cur_abc123" if len(items) > 10 else None,
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
+
return ToolResponse(
|
| 67 |
+
ok=True,
|
| 68 |
+
status=200,
|
| 69 |
+
body={
|
| 70 |
+
"messages": items[:10],
|
| 71 |
+
"next_page_token": "tok_xyz" if len(items) > 10 else None,
|
| 72 |
+
},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
def _send_message(self, params: dict) -> ToolResponse:
|
| 76 |
+
mid = f"msg_{len(self.sent_messages) + len(self.mailbox) + 1}"
|
| 77 |
+
self.sent_messages.append({"id": mid, **params})
|
| 78 |
+
return ToolResponse(
|
| 79 |
+
ok=True,
|
| 80 |
+
status=200,
|
| 81 |
+
body={"message_id": mid, "sent_at": "2026-04-25T10:00:00Z"},
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def _get_message(self, params: dict) -> ToolResponse:
|
| 85 |
+
mid = params["message_id"]
|
| 86 |
+
for m in self.mailbox:
|
| 87 |
+
if m.get("id") == mid:
|
| 88 |
+
return ToolResponse(ok=True, status=200, body=dict(m))
|
| 89 |
+
for m in self.sent_messages:
|
| 90 |
+
if m.get("id") == mid:
|
| 91 |
+
return ToolResponse(ok=True, status=200, body=dict(m))
|
| 92 |
+
return ToolResponse(ok=False, status=404, error="message not found")
|
| 93 |
+
|
| 94 |
+
def apply_drift(self, event: DriftEvent) -> None:
|
| 95 |
+
if event.kind == "field_rename" and event.endpoint == "list_messages":
|
| 96 |
+
self.active_schemas["list_messages"].response_shape = {
|
| 97 |
+
"items": "list",
|
| 98 |
+
"next_cursor": "str",
|
| 99 |
+
}
|
| 100 |
+
return
|
| 101 |
+
|
| 102 |
+
if event.kind == "endpoint_deprecation" and event.endpoint == "send_message":
|
| 103 |
+
self.active_schemas.pop("send_message", None)
|
| 104 |
+
self.handlers.pop("send_message", None)
|
| 105 |
+
new_schema = copy.deepcopy(self.baseline_schemas["send_message"])
|
| 106 |
+
new_schema.name = "messages.send"
|
| 107 |
+
self.active_schemas["messages.send"] = new_schema
|
| 108 |
+
self.handlers["messages.send"] = self._send_message
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
if event.kind == "new_required_param" and event.endpoint == "send_message":
|
| 112 |
+
schema = self.active_schemas["send_message"]
|
| 113 |
+
if "idempotency_key" not in schema.required:
|
| 114 |
+
schema.required.append("idempotency_key")
|
| 115 |
+
schema.params["idempotency_key"] = "str"
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
raise ValueError(
|
| 119 |
+
f"MailAPI.apply_drift: unhandled drift "
|
| 120 |
+
f"kind={event.kind!r} endpoint={event.endpoint!r}"
|
| 121 |
+
)
|