yashash04 commited on
Commit
8410ff1
·
1 Parent(s): 8cdb02b

Phase 2: BaseTool + MailAPI with 3 drift handlers

Browse files
Files changed (3) hide show
  1. tests/test_mail.py +149 -0
  2. tools/base.py +59 -3
  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
- Will be filled in Phase 2.
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
- Will be filled in Phase 2.
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
+ )