oneshot-python 0.23.1__tar.gz → 0.24.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/.gitignore +1 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/PKG-INFO +1 -1
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/README.md +10 -1
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/_errors.py +3 -1
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/client.py +133 -20
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/pyproject.toml +1 -1
- oneshot_python-0.24.0/tests/test_browser.py +57 -0
- oneshot_python-0.24.0/tests/test_email_payload.py +282 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/uv.lock +1 -1
- oneshot_python-0.23.1/tests/test_email_payload.py +0 -138
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/__init__.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/_types.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/physical_mail.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/__init__.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_budgets.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_charge_amount.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_compute.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_domains.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_payment_rejection.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_physical_mail.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_poll_backoff.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_wait_false.py +0 -0
- {oneshot_python-0.23.1 → oneshot_python-0.24.0}/tests/test_x402.py +0 -0
|
@@ -192,7 +192,7 @@ Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
|
192
192
|
## Links
|
|
193
193
|
|
|
194
194
|
- [Documentation](https://docs.oneshotagent.com/sdk/installation#install-via-pip-python)
|
|
195
|
-
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) —
|
|
195
|
+
- [LangChain integration](https://pypi.org/project/langchain-oneshot/) — 35 tools as LangChain BaseTool
|
|
196
196
|
- [GAME plugin](https://pypi.org/project/game-plugin-oneshot/) — Virtuals Protocol integration
|
|
197
197
|
- [TypeScript SDK](https://www.npmjs.com/package/@oneshot-agent/sdk)
|
|
198
198
|
- [MCP Server](https://www.npmjs.com/package/@oneshot-agent/mcp-server)
|
|
@@ -205,3 +205,12 @@ MIT
|
|
|
205
205
|
### Physical mail
|
|
206
206
|
|
|
207
207
|
`client.physical_mail` supports U.S. letters and 4×6 postcards through `upload_artwork`, `validate_address`, `preview`, `get_quote`, `approve`, `send`, `get_order`, `recover`, and `cancel`. Async variants prefix the method with `a`. Review each proof and price, then call `approve(..., approved=True)` explicitly. Persist `idempotency_key` before sending. `recover(key)` retrieves the original order without creating another charge. Postal delivery does not prove readership; paid receipts and fulfillment events remain separate. Lob credentials stay on OneShot's server.
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
### Account login and 2FA (0.24.0+)
|
|
211
|
+
|
|
212
|
+
Use `create_browser_profile(name)`, then `start_browser_profile_setup(profile_id, login_url)`. Poll `get_browser_profile_setup(profile_id)` until `status` is `idle`, and show the private `live_url` to the user. The live browser currently uses the Browser-Use domain. The user enters their credentials and completes 2FA there; after they finish, call `finish_browser_profile_setup(profile_id)` to save the session. Async variants use an `a` prefix.
|
|
213
|
+
|
|
214
|
+
Finish inspects cookie metadata in a fresh browser before navigation, without returning values. Later `browser(profile_id=..., task=...)` calls reuse the saved profile; verify site authentication with a read before relying on it. Setup expires after 15 minutes, with abandoned-session cleanup by the lifecycle sweep; the profile is retained for reconnection. Never log private live URLs or put credentials/codes in task prompts.
|
|
215
|
+
|
|
216
|
+
Alternatively, `create_browser_profile(name, cookies=[...])` or `storage_state={...}` imports cookies and localStorage. The legacy browser `secrets` field is rejected, and `max_steps` must be an integer from 25 through 100. Failed `JobError`s expose `code` and `partial_result`. Browser `cost` is provider cost; `billed_cost` is the reconciled customer charge, or null while pending.
|
|
@@ -44,10 +44,12 @@ class PaymentError(OneShotError):
|
|
|
44
44
|
class JobError(OneShotError):
|
|
45
45
|
"""Async job completed with an error."""
|
|
46
46
|
|
|
47
|
-
def __init__(self, message: str, job_id: str, job_error: str) -> None:
|
|
47
|
+
def __init__(self, message: str, job_id: str, job_error: str, code: str | None = None, partial_result: dict | None = None) -> None:
|
|
48
48
|
super().__init__(message)
|
|
49
49
|
self.job_id = job_id
|
|
50
50
|
self.job_error = job_error
|
|
51
|
+
self.code = code
|
|
52
|
+
self.partial_result = partial_result
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
class JobTimeoutError(OneShotError):
|
|
@@ -51,7 +51,7 @@ try:
|
|
|
51
51
|
|
|
52
52
|
SDK_VERSION = _pkg_version("oneshot-python")
|
|
53
53
|
except Exception: # pragma: no cover - editable/source runs without dist metadata
|
|
54
|
-
SDK_VERSION = "0.
|
|
54
|
+
SDK_VERSION = "0.24.0"
|
|
55
55
|
|
|
56
56
|
# ---------------------------------------------------------------------------
|
|
57
57
|
# Environment configuration
|
|
@@ -95,13 +95,26 @@ def _build_email_payload(
|
|
|
95
95
|
raise ValidationError("to is required unless reply_to_email_id is set", "to")
|
|
96
96
|
if not subject:
|
|
97
97
|
raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
# Rotation mode: when the caller passes neither from_domain nor
|
|
99
|
+
# from_mailbox, omit from_address entirely so the server picks from the
|
|
100
|
+
# agent's domain pool (mirrors the TS SDK's rotation contract). Pinning
|
|
101
|
+
# from_mailbox alone has no domain to attach it to — "oneshotagent.com"
|
|
102
|
+
# is OneShot's own domain and no agent can send from it — so that
|
|
103
|
+
# combination is a client-side error rather than a guaranteed
|
|
104
|
+
# domain_not_owned rejection from the server.
|
|
100
105
|
payload: dict[str, Any] = {
|
|
101
|
-
"from_address": f"{mailbox}@{domain}",
|
|
102
106
|
"body": body,
|
|
103
107
|
**extra,
|
|
104
108
|
}
|
|
109
|
+
if from_domain:
|
|
110
|
+
mailbox = from_mailbox or "agent"
|
|
111
|
+
payload["from_address"] = f"{mailbox}@{from_domain}"
|
|
112
|
+
elif from_mailbox:
|
|
113
|
+
raise ValidationError(
|
|
114
|
+
"from_mailbox was pinned without from_domain. Pass from_domain (a domain you own) as well, "
|
|
115
|
+
"or omit both to rotate across your warmed domains.",
|
|
116
|
+
"from_domain",
|
|
117
|
+
)
|
|
105
118
|
if to is not None:
|
|
106
119
|
payload["to_address"] = to
|
|
107
120
|
if subject is not None:
|
|
@@ -505,11 +518,17 @@ class OneShotClient:
|
|
|
505
518
|
wait: bool = True,
|
|
506
519
|
wait_for_phones: bool = False,
|
|
507
520
|
phone_timeout_sec: int = 360,
|
|
521
|
+
quote_id: Optional[str] = None,
|
|
508
522
|
) -> Any:
|
|
509
523
|
"""Execute a paid tool call (async). Handles the full x402 flow.
|
|
510
524
|
|
|
511
525
|
With ``wait=False`` a queued job is returned as
|
|
512
526
|
``{"request_id": ..., "status": ...}`` instead of being polled.
|
|
527
|
+
|
|
528
|
+
Pass ``quote_id`` when the caller already obtained a quote for this
|
|
529
|
+
exact request (e.g. ``/email/send`` after ``/email/quote``) — it rides
|
|
530
|
+
on the ``x-quote-id`` header from the very first attempt, since
|
|
531
|
+
quote-based routes reject before the 402 dance even starts without it.
|
|
513
532
|
"""
|
|
514
533
|
memo = payload.get("memo")
|
|
515
534
|
if memo is not None:
|
|
@@ -540,8 +559,15 @@ class OneShotClient:
|
|
|
540
559
|
await self.aensure_budgets_synced()
|
|
541
560
|
|
|
542
561
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
|
543
|
-
# Step 1 — Initial POST (expect 402 for paid tools)
|
|
544
|
-
|
|
562
|
+
# Step 1 — Initial POST (expect 402 for paid tools). Quote-based
|
|
563
|
+
# routes (e.g. /email/send) reject with 400 quote_id_required
|
|
564
|
+
# before they ever get to the 402 dance unless x-quote-id rides
|
|
565
|
+
# on this very first attempt, so an already-obtained quote_id
|
|
566
|
+
# must be sent from the start, not just on the paid retry.
|
|
567
|
+
initial_headers = self._headers(max_cost=max_cost)
|
|
568
|
+
if quote_id:
|
|
569
|
+
initial_headers["x-quote-id"] = quote_id
|
|
570
|
+
resp = await client.post(url, headers=initial_headers, json=payload)
|
|
545
571
|
|
|
546
572
|
# The spend-budget gate rejects at quote time (403) so nothing is
|
|
547
573
|
# signed; surface it as the typed error.
|
|
@@ -597,7 +623,12 @@ class OneShotClient:
|
|
|
597
623
|
quote_data = resp.json()
|
|
598
624
|
payment_request = quote_data["payment_request"]
|
|
599
625
|
context = quote_data.get("context", {})
|
|
600
|
-
|
|
626
|
+
# Self-quoting tools (voice/sms/browser/compute) mint a fresh
|
|
627
|
+
# quote_id in this same 402's context; quote-then-send tools
|
|
628
|
+
# (email/send) already had one supplied by the caller and don't
|
|
629
|
+
# get one echoed back here — keep it rather than clobbering it
|
|
630
|
+
# with None, or the paid retry below loses x-quote-id entirely.
|
|
631
|
+
quote_id = context.get("quote_id") or quote_id
|
|
601
632
|
|
|
602
633
|
total = context.get("total") or context.get("pricing", {}).get("total")
|
|
603
634
|
if max_cost is not None and total is not None:
|
|
@@ -732,7 +763,7 @@ class OneShotClient:
|
|
|
732
763
|
) -> Any:
|
|
733
764
|
"""POST to a free endpoint (async)."""
|
|
734
765
|
url = f"{self.base_url}{endpoint}"
|
|
735
|
-
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
766
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(150.0 if endpoint.startswith("/v1/tools/browser/profiles") else 30.0)) as client:
|
|
736
767
|
resp = await client.post(url, headers=self._read_headers(), json=payload or {})
|
|
737
768
|
if not resp.is_success:
|
|
738
769
|
raise ToolError(f"POST {endpoint} failed", resp.status_code, resp.text)
|
|
@@ -1052,6 +1083,10 @@ class OneShotClient:
|
|
|
1052
1083
|
timeout_sec: int = 300,
|
|
1053
1084
|
) -> Any:
|
|
1054
1085
|
"""Run a browser automation task. Async."""
|
|
1086
|
+
if secrets is not None:
|
|
1087
|
+
raise ValueError("Browser secrets are unsupported; import cookies or storage_state into a profile")
|
|
1088
|
+
if max_steps is not None and (type(max_steps) is not int or not 25 <= max_steps <= 100):
|
|
1089
|
+
raise ValueError("max_steps must be an integer between 25 and 100")
|
|
1055
1090
|
payload: dict[str, Any] = {"task": task}
|
|
1056
1091
|
if output_schema is not None:
|
|
1057
1092
|
payload["output_schema"] = output_schema
|
|
@@ -1061,21 +1096,53 @@ class OneShotClient:
|
|
|
1061
1096
|
payload["allowed_domains"] = allowed_domains
|
|
1062
1097
|
if profile_id is not None:
|
|
1063
1098
|
payload["profile_id"] = profile_id
|
|
1064
|
-
if secrets is not None:
|
|
1065
|
-
payload["secrets"] = secrets
|
|
1066
1099
|
if max_steps is not None:
|
|
1067
1100
|
payload["max_steps"] = max_steps
|
|
1068
1101
|
return await self.acall_tool(
|
|
1069
1102
|
"/v1/tools/browser", payload, max_cost=max_cost, timeout_sec=timeout_sec,
|
|
1070
1103
|
)
|
|
1071
1104
|
|
|
1072
|
-
def create_browser_profile(self, name: str) -> dict:
|
|
1105
|
+
def create_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
|
|
1073
1106
|
"""Create a persistent browser profile. Blocking."""
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1107
|
+
if cookies is not None and storage_state is not None:
|
|
1108
|
+
raise ValueError("Provide cookies or storage_state, not both")
|
|
1109
|
+
payload = {"name": name}
|
|
1110
|
+
if cookies is not None:
|
|
1111
|
+
payload["cookies"] = cookies
|
|
1112
|
+
if storage_state is not None:
|
|
1113
|
+
payload["storage_state"] = storage_state
|
|
1114
|
+
return self.call_free_post("/v1/tools/browser/profiles", payload)
|
|
1115
|
+
|
|
1116
|
+
async def acreate_browser_profile(self, name: str, *, cookies: Optional[list] = None, storage_state: Optional[dict] = None) -> dict:
|
|
1077
1117
|
"""Create a persistent browser profile. Async."""
|
|
1078
|
-
|
|
1118
|
+
if cookies is not None and storage_state is not None:
|
|
1119
|
+
raise ValueError("Provide cookies or storage_state, not both")
|
|
1120
|
+
payload = {"name": name}
|
|
1121
|
+
if cookies is not None:
|
|
1122
|
+
payload["cookies"] = cookies
|
|
1123
|
+
if storage_state is not None:
|
|
1124
|
+
payload["storage_state"] = storage_state
|
|
1125
|
+
return await self.acall_free_post("/v1/tools/browser/profiles", payload)
|
|
1126
|
+
|
|
1127
|
+
def start_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
|
|
1128
|
+
"""Open login/2FA live view. Do not log the returned live_url."""
|
|
1129
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
|
|
1130
|
+
|
|
1131
|
+
async def astart_browser_profile_setup(self, profile_id: str, start_url: str) -> dict:
|
|
1132
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/start", {"start_url": start_url})
|
|
1133
|
+
|
|
1134
|
+
def get_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1135
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
|
|
1136
|
+
|
|
1137
|
+
async def aget_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1138
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/status", {})
|
|
1139
|
+
|
|
1140
|
+
def finish_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1141
|
+
"""Save after human login/2FA; inspect saved cookies before navigation."""
|
|
1142
|
+
return self.call_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
|
|
1143
|
+
|
|
1144
|
+
async def afinish_browser_profile_setup(self, profile_id: str) -> dict:
|
|
1145
|
+
return await self.acall_free_post(f"/v1/tools/browser/profiles/{quote(profile_id, safe='')}/setup/finish", {})
|
|
1079
1146
|
|
|
1080
1147
|
def list_browser_profiles(self) -> list:
|
|
1081
1148
|
"""List all browser profiles. Blocking."""
|
|
@@ -1160,9 +1227,13 @@ class OneShotClient:
|
|
|
1160
1227
|
``mailbox_mode == 'mailbox'``, and read ``mailbox_provisioning_fee`` on
|
|
1161
1228
|
the quote for the exact amount before paying.
|
|
1162
1229
|
"""
|
|
1163
|
-
return
|
|
1164
|
-
|
|
1165
|
-
|
|
1230
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
1231
|
+
self.aemail(
|
|
1232
|
+
to, subject, body,
|
|
1233
|
+
from_domain=from_domain, from_mailbox=from_mailbox, from_name=from_name,
|
|
1234
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1235
|
+
**kwargs,
|
|
1236
|
+
)
|
|
1166
1237
|
)
|
|
1167
1238
|
|
|
1168
1239
|
async def aemail(
|
|
@@ -1186,12 +1257,53 @@ class OneShotClient:
|
|
|
1186
1257
|
|
|
1187
1258
|
``mailbox_mode`` ('relay' | 'mailbox', default 'relay') selects how a
|
|
1188
1259
|
domain sends — see ``email()`` for details.
|
|
1260
|
+
|
|
1261
|
+
Quotes before sending — mirrors the TS SDK (``index.ts`` ``email()``).
|
|
1262
|
+
``/v1/tools/email/send`` requires an ``x-quote-id`` from a prior
|
|
1263
|
+
``/v1/tools/email/quote`` call and 400s without one, and rotation mode
|
|
1264
|
+
(both ``from_domain``/``from_mailbox`` omitted) is decided at quote
|
|
1265
|
+
time and locked server-side for the matching send — so quoting first
|
|
1266
|
+
is not optional, it's the only way rotation (or any send) works.
|
|
1189
1267
|
"""
|
|
1268
|
+
# max_cost/timeout_sec are transport options for acall_tool, not
|
|
1269
|
+
# send-body fields — pull them out of kwargs before they reach
|
|
1270
|
+
# _build_email_payload, or they'd ride along in the JSON body (which
|
|
1271
|
+
# the server's Zod schema silently drops) instead of driving the
|
|
1272
|
+
# X-Max-Cost-USDC header + local budget check and the poll timeout.
|
|
1273
|
+
max_cost = kwargs.pop("max_cost", None)
|
|
1274
|
+
timeout_sec = kwargs.pop("timeout_sec", 120)
|
|
1275
|
+
|
|
1276
|
+
# Quote payload mirrors the TS SDK's quote body: no from_name/extra
|
|
1277
|
+
# kwargs (attachments, idempotencyKey, etc. are send-only concerns).
|
|
1278
|
+
quote_payload = _build_email_payload(
|
|
1279
|
+
to, subject, body, from_domain, from_mailbox, None, {},
|
|
1280
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1281
|
+
)
|
|
1282
|
+
quote = await self.acall_tool(
|
|
1283
|
+
"/v1/tools/email/quote", quote_payload, max_cost=max_cost,
|
|
1284
|
+
)
|
|
1285
|
+
quote_id = quote.get("quote_id") if isinstance(quote, dict) else None
|
|
1286
|
+
quoted_from_address = quote.get("from_address") if isinstance(quote, dict) else None
|
|
1287
|
+
|
|
1288
|
+
send_payload = _build_email_payload(
|
|
1289
|
+
to, subject, body, from_domain, from_mailbox, from_name, kwargs,
|
|
1290
|
+
reply_to_email_id=reply_to_email_id, mailbox_mode=mailbox_mode,
|
|
1291
|
+
)
|
|
1292
|
+
# Replay the quote-locked/rotated address explicitly. The server
|
|
1293
|
+
# already replays it from the quote row when from_address is absent,
|
|
1294
|
+
# but sending it back is harmless and mirrors the TS SDK
|
|
1295
|
+
# (index.ts:425,431) so a future /send-without-quote revision keeps
|
|
1296
|
+
# working unchanged.
|
|
1297
|
+
resolved_from_address = send_payload.get("from_address") or quoted_from_address
|
|
1298
|
+
if resolved_from_address:
|
|
1299
|
+
send_payload["from_address"] = resolved_from_address
|
|
1300
|
+
|
|
1190
1301
|
return await self.acall_tool(
|
|
1191
|
-
"/v1/tools/email/send",
|
|
1192
|
-
|
|
1302
|
+
"/v1/tools/email/send", send_payload, quote_id=quote_id,
|
|
1303
|
+
max_cost=max_cost, timeout_sec=timeout_sec,
|
|
1193
1304
|
)
|
|
1194
1305
|
|
|
1306
|
+
|
|
1195
1307
|
def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
|
|
1196
1308
|
"""Make an AI voice call. Blocking."""
|
|
1197
1309
|
return self.call_tool("/v1/tools/voice/call", {"objective": objective, "target_number": target_number, **kwargs})
|
|
@@ -1556,6 +1668,7 @@ class OneShotClient:
|
|
|
1556
1668
|
f"Job failed: {job.get('error', 'Unknown')}",
|
|
1557
1669
|
request_id,
|
|
1558
1670
|
str(job.get("error", "Unknown")),
|
|
1671
|
+
code=job.get("error_code"), partial_result=job.get("result"),
|
|
1559
1672
|
)
|
|
1560
1673
|
|
|
1561
1674
|
retries = 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.24.0"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling — 35 tools"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Browser credentials bypass paid task prose; invalid requests fail locally."""
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from oneshot.client import OneShotClient
|
|
6
|
+
from oneshot._errors import JobError
|
|
7
|
+
|
|
8
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
@pytest.mark.parametrize('options', [{'secrets': {}}, {'max_steps': 3}, {'max_steps': 25.5}, {'max_steps': True}])
|
|
13
|
+
async def test_invalid_task_never_calls_paid_endpoint(options):
|
|
14
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
15
|
+
client.acall_tool = AsyncMock()
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
await client.abrowser('Read heading', **options)
|
|
18
|
+
client.acall_tool.assert_not_awaited()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_import_uses_profile_endpoint_and_rejects_ambiguous_state():
|
|
23
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
24
|
+
client.acall_free_post = AsyncMock(return_value={'id': 'profile'})
|
|
25
|
+
state = {'cookies': [], 'origins': []}
|
|
26
|
+
assert await client.acreate_browser_profile('test', storage_state=state) == {'id': 'profile'}
|
|
27
|
+
client.acall_free_post.assert_awaited_once_with('/v1/tools/browser/profiles', {'name': 'test', 'storage_state': state})
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
await client.acreate_browser_profile('test', cookies=[], storage_state=state)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_failed_poll_preserves_partial_browser_result():
|
|
34
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
35
|
+
partial = {'cost': .1, 'steps': [{'number': 1, 'goal': 'Browser action', 'url': 'https://example.com'}]}
|
|
36
|
+
response = MagicMock(is_success=True, status_code=200)
|
|
37
|
+
response.json.return_value = {'status': 'failed', 'error_message': 'Browser timeout', 'error_code': 'browser_timeout', 'result': partial}
|
|
38
|
+
http = MagicMock()
|
|
39
|
+
http.get = AsyncMock(return_value=response)
|
|
40
|
+
with pytest.raises(JobError) as caught:
|
|
41
|
+
await client._poll_job(http, 'request', 10)
|
|
42
|
+
assert caught.value.code == 'browser_timeout'
|
|
43
|
+
assert caught.value.partial_result == partial
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_interactive_setup_uses_free_authenticated_operations():
|
|
48
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
49
|
+
client.acall_free_post = AsyncMock(return_value={'status': 'idle'})
|
|
50
|
+
await client.astart_browser_profile_setup('profile', 'https://example.com/login')
|
|
51
|
+
await client.aget_browser_profile_setup('profile')
|
|
52
|
+
await client.afinish_browser_profile_setup('profile')
|
|
53
|
+
assert [(call.args[0],call.args[1]) for call in client.acall_free_post.await_args_list] == [
|
|
54
|
+
('/v1/tools/browser/profiles/profile/setup/start', {'start_url':'https://example.com/login'}),
|
|
55
|
+
('/v1/tools/browser/profiles/profile/setup/status', {}),
|
|
56
|
+
('/v1/tools/browser/profiles/profile/setup/finish', {}),
|
|
57
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Tests for the email payload construction in OneShotClient.
|
|
2
|
+
|
|
3
|
+
Verifies that email()/aemail() send the API's field convention
|
|
4
|
+
(from_address + to_address), build from_address from from_mailbox/from_domain
|
|
5
|
+
when a domain is pinned, omit from_address entirely to trigger server-side
|
|
6
|
+
rotation when both are omitted, reject a from_mailbox pinned without
|
|
7
|
+
from_domain, and forward the optional from_name display name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from oneshot._errors import ValidationError
|
|
18
|
+
from oneshot.client import OneShotClient, _build_email_payload
|
|
19
|
+
|
|
20
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
21
|
+
REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def _fresh_event_loop():
|
|
26
|
+
"""Give every test in this module its own event loop.
|
|
27
|
+
|
|
28
|
+
The sync surface (``email()``) bridges to ``aemail()`` via
|
|
29
|
+
``asyncio.get_event_loop().run_until_complete(...)``. pytest-asyncio
|
|
30
|
+
clears the current event loop after each async test, which otherwise
|
|
31
|
+
breaks any *sync* test — mocked or not — that happens to run afterward
|
|
32
|
+
in the same session ("There is no current event loop in thread
|
|
33
|
+
'MainThread'"). Without this fixture the suite's pass/fail would depend
|
|
34
|
+
on test collection order, which is exactly the kind of environment-
|
|
35
|
+
dependent test this correction round was asked to avoid introducing.
|
|
36
|
+
"""
|
|
37
|
+
loop = asyncio.new_event_loop()
|
|
38
|
+
asyncio.set_event_loop(loop)
|
|
39
|
+
yield
|
|
40
|
+
loop.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def test_payload_omits_from_address_when_no_from_knobs_given():
|
|
46
|
+
# Rotation mode: matches the TS SDK's contract — never fabricate
|
|
47
|
+
# `agent@oneshotagent.com`. The server picks a domain from the agent's
|
|
48
|
+
# own pool and the caller replays whatever the quote returns.
|
|
49
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
|
|
50
|
+
assert p == {
|
|
51
|
+
"to_address": "r@x.com",
|
|
52
|
+
"subject": "S",
|
|
53
|
+
"body": "B",
|
|
54
|
+
}
|
|
55
|
+
assert "from_address" not in p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_payload_uses_mailbox_domain_and_name():
|
|
59
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
|
|
60
|
+
assert p["from_address"] == "jane@acme.com"
|
|
61
|
+
assert p["from_name"] == "Jane Doe"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_payload_from_domain_alone_defaults_mailbox_to_agent():
|
|
65
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
|
|
66
|
+
assert p["from_address"] == "agent@acme.com"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_payload_from_mailbox_without_from_domain_raises():
|
|
70
|
+
# No default domain to fall back to — oneshotagent.com is OneShot's own
|
|
71
|
+
# domain and no agent can send from it. This must be a client-side
|
|
72
|
+
# ValidationError naming from_domain, never a fabricated address.
|
|
73
|
+
with pytest.raises(ValidationError, match="from_domain"):
|
|
74
|
+
_build_email_payload("r@x.com", "S", "B", None, "jane", None, {})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_payload_omits_empty_from_name():
|
|
78
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
|
|
79
|
+
assert "from_name" not in p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_payload_passes_extra_kwargs():
|
|
83
|
+
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
|
|
84
|
+
assert p["wait"] is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_payload_forwards_mailbox_mode():
|
|
88
|
+
# mailbox_mode is an explicit keyword-only param, forwarded into the send body so
|
|
89
|
+
# the API's per-domain provider override + fee logic sees it.
|
|
90
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
|
|
91
|
+
assert p["mailbox_mode"] == "mailbox"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_payload_omits_mailbox_mode_when_absent():
|
|
95
|
+
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
|
|
96
|
+
assert "mailbox_mode" not in p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── reply threading (reply_to_email_id) ───────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def test_reply_payload_omits_to_and_subject_when_derivable():
|
|
102
|
+
# Replying: to/subject may be omitted; server derives them.
|
|
103
|
+
p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
104
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
105
|
+
assert p["body"] == "B"
|
|
106
|
+
assert "to_address" not in p
|
|
107
|
+
assert "subject" not in p
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_reply_payload_keeps_explicit_to_and_subject():
|
|
111
|
+
p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
112
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
113
|
+
assert p["to_address"] == "r@x.com"
|
|
114
|
+
assert p["subject"] == "Custom"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_non_reply_requires_to():
|
|
118
|
+
with pytest.raises(ValidationError):
|
|
119
|
+
_build_email_payload(None, "S", "B", None, None, None, {})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_non_reply_requires_subject():
|
|
123
|
+
with pytest.raises(ValidationError):
|
|
124
|
+
_build_email_payload("r@x.com", None, "B", None, None, None, {})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_reply_does_not_require_to_or_subject():
|
|
128
|
+
# Should not raise even though to and subject are None.
|
|
129
|
+
_build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── email()/aemail() quote-then-send flow ──────────────────────────────────
|
|
133
|
+
#
|
|
134
|
+
# email()/aemail() now quote before sending (mirrors the TS SDK's
|
|
135
|
+
# email/quote -> email/send flow in libs/agent-sdk/src/index.ts). These tests
|
|
136
|
+
# stub acall_tool itself (the real HTTP/x402 transport) so they exercise the
|
|
137
|
+
# actual two-request flow end-to-end, instead of stopping at the payload
|
|
138
|
+
# builder — the gap that let a Python send hard-fail with 400
|
|
139
|
+
# missing_from_address in production while the mocked-call_tool tests still
|
|
140
|
+
# passed.
|
|
141
|
+
|
|
142
|
+
def _capturing_client(
|
|
143
|
+
quote_from_address: str | None = None,
|
|
144
|
+
quote_extra: dict | None = None,
|
|
145
|
+
) -> tuple[OneShotClient, list[dict]]:
|
|
146
|
+
"""A client whose acall_tool is stubbed to capture both the /email/quote
|
|
147
|
+
and /email/send calls it makes, without touching the network."""
|
|
148
|
+
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
149
|
+
calls: list[dict] = []
|
|
150
|
+
|
|
151
|
+
async def fake_acall_tool(endpoint, payload, **kwargs):
|
|
152
|
+
calls.append({
|
|
153
|
+
"endpoint": endpoint,
|
|
154
|
+
"payload": dict(payload),
|
|
155
|
+
"quote_id": kwargs.get("quote_id"),
|
|
156
|
+
"max_cost": kwargs.get("max_cost"),
|
|
157
|
+
"timeout_sec": kwargs.get("timeout_sec"),
|
|
158
|
+
})
|
|
159
|
+
if endpoint.endswith("/email/quote"):
|
|
160
|
+
resp: dict[str, Any] = {"total_cost": "0.01", "quote_id": "quote_test_1"}
|
|
161
|
+
if quote_from_address:
|
|
162
|
+
resp["from_address"] = quote_from_address
|
|
163
|
+
if quote_extra:
|
|
164
|
+
resp.update(quote_extra)
|
|
165
|
+
return resp
|
|
166
|
+
return {"request_id": "r1", "status": "completed"}
|
|
167
|
+
|
|
168
|
+
c.acall_tool = fake_acall_tool # type: ignore[method-assign]
|
|
169
|
+
return c, calls
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find(calls: list[dict], suffix: str) -> dict:
|
|
173
|
+
for call in calls:
|
|
174
|
+
if call["endpoint"].endswith(suffix):
|
|
175
|
+
return call
|
|
176
|
+
raise AssertionError(f"no call to endpoint ending in {suffix!r} among {[c['endpoint'] for c in calls]}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_aemail_rotation_quotes_before_send_and_replays_picked_address():
|
|
181
|
+
# Rotation mode (no from_domain/from_mailbox): from_address must be
|
|
182
|
+
# omitted on the quote so the server picks a domain from the agent's own
|
|
183
|
+
# pool, and the picked address must be replayed on /send — otherwise
|
|
184
|
+
# /send 400s with missing_from_address (apps/api-service's
|
|
185
|
+
# /email/send resolves from_address only from an explicit param, a
|
|
186
|
+
# quote-locked address, or a reply target).
|
|
187
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
188
|
+
await c.aemail("r@x.com", "Hi", "Body")
|
|
189
|
+
|
|
190
|
+
quote = _find(calls, "/email/quote")
|
|
191
|
+
send = _find(calls, "/email/send")
|
|
192
|
+
assert "from_address" not in quote["payload"]
|
|
193
|
+
assert send["payload"]["from_address"] == "agent@picked.com"
|
|
194
|
+
assert send["payload"]["to_address"] == "r@x.com"
|
|
195
|
+
# /email/send requires x-quote-id from the very first attempt (the API
|
|
196
|
+
# 400s quote_id_required / "Quote ID required" otherwise) — acall_tool
|
|
197
|
+
# must receive the quote's id so it can set the header on every attempt.
|
|
198
|
+
assert send["quote_id"] == "quote_test_1"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_email_sends_correct_contract():
|
|
202
|
+
c, calls = _capturing_client()
|
|
203
|
+
c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
|
|
204
|
+
quote = _find(calls, "/email/quote")
|
|
205
|
+
send = _find(calls, "/email/send")
|
|
206
|
+
assert quote["payload"]["from_address"] == "jane@acme.com"
|
|
207
|
+
assert send["payload"]["from_address"] == "jane@acme.com"
|
|
208
|
+
assert send["payload"]["to_address"] == "r@x.com"
|
|
209
|
+
assert send["payload"]["from_name"] == "Jane Doe"
|
|
210
|
+
# from_name is a display-only field for the send, not part of pricing —
|
|
211
|
+
# mirrors the TS SDK quote body, which never sends it either.
|
|
212
|
+
assert "from_name" not in quote["payload"]
|
|
213
|
+
assert send["quote_id"] == "quote_test_1"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_email_reply_forwards_reply_id_without_to_subject():
|
|
217
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
218
|
+
c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
|
|
219
|
+
quote = _find(calls, "/email/quote")
|
|
220
|
+
send = _find(calls, "/email/send")
|
|
221
|
+
for call in (quote, send):
|
|
222
|
+
assert call["payload"]["reply_to_email_id"] == REPLY_ID
|
|
223
|
+
assert call["payload"]["body"] == "Thanks!"
|
|
224
|
+
assert "to_address" not in call["payload"]
|
|
225
|
+
assert "subject" not in call["payload"]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_email_without_to_or_reply_id_raises():
|
|
229
|
+
c, calls = _capturing_client()
|
|
230
|
+
with pytest.raises(ValidationError):
|
|
231
|
+
c.email(body="orphan body")
|
|
232
|
+
assert calls == [] # fails before any network call — quote or send
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_email_from_mailbox_without_from_domain_raises():
|
|
236
|
+
# Public email() surface, not just the pure _build_email_payload helper:
|
|
237
|
+
# pinning from_mailbox alone must fail client-side naming from_domain,
|
|
238
|
+
# never send agent@oneshotagent.com — checked before quoting, so no
|
|
239
|
+
# network call is made at all.
|
|
240
|
+
c, calls = _capturing_client()
|
|
241
|
+
with pytest.raises(ValidationError, match="from_domain"):
|
|
242
|
+
c.email("r@x.com", "Hi", "Body", from_mailbox="jane")
|
|
243
|
+
assert calls == []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── max_cost / timeout_sec forwarding (finding PRRT_kwDOQhndvM6gpNlg) ──────
|
|
247
|
+
#
|
|
248
|
+
# aemail() takes max_cost/timeout_sec via **kwargs alongside genuine send-body
|
|
249
|
+
# extras (attachments, idempotencyKey, ...). Before this fix they fell straight
|
|
250
|
+
# through to _build_email_payload and rode along in the JSON body — never
|
|
251
|
+
# reaching acall_tool, so the X-Max-Cost-USDC header + local budget check never
|
|
252
|
+
# fired on the quote or send call, and the poll used the 120s default no
|
|
253
|
+
# matter what the caller asked for.
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_aemail_forwards_max_cost_and_timeout_to_transport_not_body():
|
|
257
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
258
|
+
await c.aemail("r@x.com", "Hi", "Body", max_cost=0.05, timeout_sec=30)
|
|
259
|
+
|
|
260
|
+
quote = _find(calls, "/email/quote")
|
|
261
|
+
send = _find(calls, "/email/send")
|
|
262
|
+
# Reached acall_tool as transport options...
|
|
263
|
+
assert quote["max_cost"] == 0.05
|
|
264
|
+
assert send["max_cost"] == 0.05
|
|
265
|
+
assert send["timeout_sec"] == 30
|
|
266
|
+
# ...and did NOT leak into the JSON payload either call sends.
|
|
267
|
+
assert "max_cost" not in quote["payload"]
|
|
268
|
+
assert "max_cost" not in send["payload"]
|
|
269
|
+
assert "timeout_sec" not in quote["payload"]
|
|
270
|
+
assert "timeout_sec" not in send["payload"]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.asyncio
|
|
274
|
+
async def test_aemail_default_timeout_sec_is_120_when_unset():
|
|
275
|
+
c, calls = _capturing_client(quote_from_address="agent@picked.com")
|
|
276
|
+
await c.aemail("r@x.com", "Hi", "Body")
|
|
277
|
+
|
|
278
|
+
send = _find(calls, "/email/send")
|
|
279
|
+
assert send["timeout_sec"] == 120
|
|
280
|
+
quote = _find(calls, "/email/quote")
|
|
281
|
+
assert quote["max_cost"] is None
|
|
282
|
+
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""Tests for the email payload construction in OneShotClient.
|
|
2
|
-
|
|
3
|
-
Verifies that email()/aemail() send the API's field convention
|
|
4
|
-
(from_address + to_address), build from_address from from_mailbox/from_domain,
|
|
5
|
-
and forward the optional from_name display name.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from unittest.mock import AsyncMock, MagicMock
|
|
11
|
-
|
|
12
|
-
import pytest
|
|
13
|
-
|
|
14
|
-
from oneshot._errors import ValidationError
|
|
15
|
-
from oneshot.client import OneShotClient, _build_email_payload
|
|
16
|
-
|
|
17
|
-
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
18
|
-
REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
def test_payload_defaults_to_agent_mailbox():
|
|
24
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {})
|
|
25
|
-
assert p == {
|
|
26
|
-
"from_address": "agent@oneshotagent.com",
|
|
27
|
-
"to_address": "r@x.com",
|
|
28
|
-
"subject": "S",
|
|
29
|
-
"body": "B",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_payload_uses_mailbox_domain_and_name():
|
|
34
|
-
p = _build_email_payload("r@x.com", "S", "B", "acme.com", "jane", "Jane Doe", {})
|
|
35
|
-
assert p["from_address"] == "jane@acme.com"
|
|
36
|
-
assert p["from_name"] == "Jane Doe"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_payload_omits_empty_from_name():
|
|
40
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, "", {})
|
|
41
|
-
assert "from_name" not in p
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_payload_passes_extra_kwargs():
|
|
45
|
-
p = _build_email_payload("r@x.com", "S", "B", None, None, None, {"wait": True})
|
|
46
|
-
assert p["wait"] is True
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_payload_forwards_mailbox_mode():
|
|
50
|
-
# mailbox_mode is an explicit keyword-only param, forwarded into the send body so
|
|
51
|
-
# the API's per-domain provider override + fee logic sees it.
|
|
52
|
-
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {}, mailbox_mode="mailbox")
|
|
53
|
-
assert p["mailbox_mode"] == "mailbox"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_payload_omits_mailbox_mode_when_absent():
|
|
57
|
-
p = _build_email_payload("r@x.com", "S", "B", "acme.com", None, None, {})
|
|
58
|
-
assert "mailbox_mode" not in p
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# ── reply threading (reply_to_email_id) ───────────────────────────────────
|
|
62
|
-
|
|
63
|
-
def test_reply_payload_omits_to_and_subject_when_derivable():
|
|
64
|
-
# Replying: to/subject may be omitted; server derives them.
|
|
65
|
-
p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
66
|
-
assert p["reply_to_email_id"] == REPLY_ID
|
|
67
|
-
assert p["body"] == "B"
|
|
68
|
-
assert "to_address" not in p
|
|
69
|
-
assert "subject" not in p
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def test_reply_payload_keeps_explicit_to_and_subject():
|
|
73
|
-
p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
74
|
-
assert p["reply_to_email_id"] == REPLY_ID
|
|
75
|
-
assert p["to_address"] == "r@x.com"
|
|
76
|
-
assert p["subject"] == "Custom"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_non_reply_requires_to():
|
|
80
|
-
with pytest.raises(ValidationError):
|
|
81
|
-
_build_email_payload(None, "S", "B", None, None, None, {})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_non_reply_requires_subject():
|
|
85
|
-
with pytest.raises(ValidationError):
|
|
86
|
-
_build_email_payload("r@x.com", None, "B", None, None, None, {})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_reply_does_not_require_to_or_subject():
|
|
90
|
-
# Should not raise even though to and subject are None.
|
|
91
|
-
_build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# ── email() delegation ────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
def _client() -> OneShotClient:
|
|
97
|
-
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
98
|
-
c.call_tool = MagicMock(return_value={"request_id": "r1"}) # type: ignore[method-assign]
|
|
99
|
-
return c
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_email_sends_correct_contract():
|
|
103
|
-
c = _client()
|
|
104
|
-
c.email("r@x.com", "Hi", "Body", from_mailbox="jane", from_domain="acme.com", from_name="Jane Doe")
|
|
105
|
-
endpoint, payload = c.call_tool.call_args[0]
|
|
106
|
-
assert endpoint == "/v1/tools/email/send"
|
|
107
|
-
assert payload["from_address"] == "jane@acme.com"
|
|
108
|
-
assert payload["to_address"] == "r@x.com"
|
|
109
|
-
assert payload["from_name"] == "Jane Doe"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@pytest.mark.asyncio
|
|
113
|
-
async def test_aemail_sends_correct_contract():
|
|
114
|
-
c = OneShotClient(TEST_PRIVATE_KEY)
|
|
115
|
-
c.acall_tool = AsyncMock(return_value={"request_id": "r2"}) # type: ignore[method-assign]
|
|
116
|
-
await c.aemail("r@x.com", "Hi", "Body")
|
|
117
|
-
endpoint, payload = c.acall_tool.call_args[0]
|
|
118
|
-
assert endpoint == "/v1/tools/email/send"
|
|
119
|
-
assert payload["from_address"] == "agent@oneshotagent.com"
|
|
120
|
-
assert payload["to_address"] == "r@x.com"
|
|
121
|
-
assert "from_name" not in payload
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_email_reply_forwards_reply_id_without_to_subject():
|
|
125
|
-
c = _client()
|
|
126
|
-
c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
|
|
127
|
-
endpoint, payload = c.call_tool.call_args[0]
|
|
128
|
-
assert endpoint == "/v1/tools/email/send"
|
|
129
|
-
assert payload["reply_to_email_id"] == REPLY_ID
|
|
130
|
-
assert payload["body"] == "Thanks!"
|
|
131
|
-
assert "to_address" not in payload
|
|
132
|
-
assert "subject" not in payload
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def test_email_without_to_or_reply_id_raises():
|
|
136
|
-
c = _client()
|
|
137
|
-
with pytest.raises(ValidationError):
|
|
138
|
-
c.email(body="orphan body")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|