oneshot-python 0.11.0__tar.gz → 0.12.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.11.0 → oneshot_python-0.12.0}/PKG-INFO +1 -1
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/README.md +31 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/__init__.py +22 -0
- oneshot_python-0.12.0/oneshot/_types.py +178 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/client.py +242 -14
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/pyproject.toml +1 -1
- oneshot_python-0.12.0/tests/test_compute.py +291 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_email_payload.py +52 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/.gitignore +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/__init__.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_emergency_error.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_max_cost_header.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_phones_pending.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_request_id.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_tag_receipt_value.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/tests/test_x402.py +0 -0
- {oneshot_python-0.11.0 → oneshot_python-0.12.0}/uv.lock +0 -0
|
@@ -87,6 +87,37 @@ The SDK operates on **Base Mainnet** with real USDC. Fund your wallet before mak
|
|
|
87
87
|
- `call_free_post(endpoint, payload=None)` / `acall_free_post(...)` — POST
|
|
88
88
|
- `call_free_patch(endpoint, payload=None)` / `acall_free_patch(...)` — PATCH
|
|
89
89
|
|
|
90
|
+
### Compute — autonomous goal orchestration
|
|
91
|
+
|
|
92
|
+
Launch a compute goal and let the orchestrator plan, execute, and iterate. Paid via x402 (same flow as other tools).
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
goal = client.compute(
|
|
96
|
+
objective="Research the top 10 AI startups and build a comparison website",
|
|
97
|
+
budget_usdc=5.00,
|
|
98
|
+
max_cost=10.00,
|
|
99
|
+
)
|
|
100
|
+
print(goal["goal_id"])
|
|
101
|
+
|
|
102
|
+
# Observe
|
|
103
|
+
status = client.get_compute_goal(goal["goal_id"])
|
|
104
|
+
tasks = client.get_compute_tasks(goal["goal_id"])
|
|
105
|
+
budget = client.get_compute_budget(goal["goal_id"])
|
|
106
|
+
|
|
107
|
+
# Top up a recurring goal (paid)
|
|
108
|
+
client.fund_compute_goal(goal["goal_id"], 10.00)
|
|
109
|
+
|
|
110
|
+
# Lifecycle
|
|
111
|
+
client.pause_compute_goal(goal["goal_id"])
|
|
112
|
+
client.resume_compute_goal(goal["goal_id"])
|
|
113
|
+
client.cancel_compute_goal(goal["goal_id"], reason="done")
|
|
114
|
+
|
|
115
|
+
# HITL — respond to an approval task
|
|
116
|
+
client.respond_to_compute_task(goal["goal_id"], "task_01H…", approved=True)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Every method has an `a*` async mirror (`acompute`, `aget_compute_goal`, …).
|
|
120
|
+
|
|
90
121
|
## Requirements
|
|
91
122
|
|
|
92
123
|
- Python 3.10+
|
|
@@ -9,6 +9,18 @@ from oneshot._errors import (
|
|
|
9
9
|
ToolError,
|
|
10
10
|
ValidationError,
|
|
11
11
|
)
|
|
12
|
+
from oneshot._types import (
|
|
13
|
+
ComputeBudgetStatus,
|
|
14
|
+
ComputeCancelResult,
|
|
15
|
+
ComputeFundResult,
|
|
16
|
+
ComputeGoalResult,
|
|
17
|
+
ComputeGoalStatus,
|
|
18
|
+
ComputePauseResult,
|
|
19
|
+
ComputeResumeResult,
|
|
20
|
+
ComputeSchedule,
|
|
21
|
+
ComputeTask,
|
|
22
|
+
ComputeTaskResponseResult,
|
|
23
|
+
)
|
|
12
24
|
from oneshot.client import OneShotClient
|
|
13
25
|
from oneshot.x402 import sign_payment_authorization
|
|
14
26
|
|
|
@@ -22,4 +34,14 @@ __all__ = [
|
|
|
22
34
|
"ContentBlockedError",
|
|
23
35
|
"EmergencyNumberError",
|
|
24
36
|
"sign_payment_authorization",
|
|
37
|
+
"ComputeSchedule",
|
|
38
|
+
"ComputeGoalResult",
|
|
39
|
+
"ComputeGoalStatus",
|
|
40
|
+
"ComputeTask",
|
|
41
|
+
"ComputeBudgetStatus",
|
|
42
|
+
"ComputeCancelResult",
|
|
43
|
+
"ComputeTaskResponseResult",
|
|
44
|
+
"ComputePauseResult",
|
|
45
|
+
"ComputeResumeResult",
|
|
46
|
+
"ComputeFundResult",
|
|
25
47
|
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Type hints for the compute orchestration API.
|
|
2
|
+
|
|
3
|
+
These TypedDicts mirror libs/agent-sdk/src/types.ts field-for-field. They are
|
|
4
|
+
optional at runtime — `OneShotClient` continues to return raw dicts — but
|
|
5
|
+
expose the shape to IDEs and `mypy` so callers get auto-complete + type
|
|
6
|
+
checking when they want it.
|
|
7
|
+
|
|
8
|
+
Keep the field set IN SYNC with the TS interfaces. If you add a field here,
|
|
9
|
+
add it there (and vice versa).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional, TypedDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ComputeSchedule(TypedDict, total=False):
|
|
18
|
+
"""Recurring-goal schedule. Mirrors `ComputeSchedule` in TS."""
|
|
19
|
+
|
|
20
|
+
cron: str
|
|
21
|
+
budget_per_run: float
|
|
22
|
+
max_runs: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ComputeGoalInner(TypedDict, total=False):
|
|
26
|
+
objective: str
|
|
27
|
+
budget_usdc: str
|
|
28
|
+
deadline: str
|
|
29
|
+
schedule_cron: str
|
|
30
|
+
budget_per_run: float
|
|
31
|
+
max_runs: int
|
|
32
|
+
next_run_at: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ComputeGoalResult(TypedDict, total=False):
|
|
36
|
+
"""Returned by `compute()` / `acompute()` after the goal is created."""
|
|
37
|
+
|
|
38
|
+
goal_id: str
|
|
39
|
+
request_id: str
|
|
40
|
+
receipt_id: str
|
|
41
|
+
status: str
|
|
42
|
+
message: str
|
|
43
|
+
memo: str
|
|
44
|
+
cost: float
|
|
45
|
+
goal: ComputeGoalInner
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ComputeBudgetInner(TypedDict, total=False):
|
|
49
|
+
total: str
|
|
50
|
+
spent: str
|
|
51
|
+
reserved: str
|
|
52
|
+
remaining: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ComputeGoalScheduleInner(TypedDict, total=False):
|
|
56
|
+
cron: str
|
|
57
|
+
budget_per_run: str
|
|
58
|
+
max_runs: Optional[int]
|
|
59
|
+
run_count: int
|
|
60
|
+
last_run_at: Optional[str]
|
|
61
|
+
next_run_at: Optional[str]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ComputeGoalStatus(TypedDict, total=False):
|
|
65
|
+
"""Returned by `get_compute_goal()` / `aget_compute_goal()`."""
|
|
66
|
+
|
|
67
|
+
id: str
|
|
68
|
+
status: str
|
|
69
|
+
name: str
|
|
70
|
+
objective: str
|
|
71
|
+
current_phase: Optional[int]
|
|
72
|
+
plan: Any
|
|
73
|
+
budget: Optional[ComputeBudgetInner]
|
|
74
|
+
soul_agent_id: Optional[str]
|
|
75
|
+
deadline: Optional[str]
|
|
76
|
+
started_at: Optional[str]
|
|
77
|
+
completed_at: Optional[str]
|
|
78
|
+
last_wake_at: Optional[str]
|
|
79
|
+
next_wake_at: Optional[str]
|
|
80
|
+
created_at: str
|
|
81
|
+
schedule: ComputeGoalScheduleInner
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ComputeTask(TypedDict, total=False):
|
|
85
|
+
"""Single item from `get_compute_tasks()` / `aget_compute_tasks()`."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
task_type: str
|
|
89
|
+
tool: Optional[str]
|
|
90
|
+
description: str
|
|
91
|
+
status: str
|
|
92
|
+
phase: Optional[int]
|
|
93
|
+
sequence: Optional[int]
|
|
94
|
+
progress_pct: Optional[float]
|
|
95
|
+
progress_message: Optional[str]
|
|
96
|
+
result: Any
|
|
97
|
+
quoted_usdc: Optional[str]
|
|
98
|
+
actual_usdc: Optional[str]
|
|
99
|
+
run_number: Optional[int]
|
|
100
|
+
started_at: Optional[str]
|
|
101
|
+
completed_at: Optional[str]
|
|
102
|
+
created_at: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ComputeBudgetSpendEntry(TypedDict, total=False):
|
|
106
|
+
category: str
|
|
107
|
+
amount_usdc: str
|
|
108
|
+
description: str
|
|
109
|
+
created_at: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ComputeBudgetStatus(TypedDict, total=False):
|
|
113
|
+
"""Returned by `get_compute_budget()` / `aget_compute_budget()`."""
|
|
114
|
+
|
|
115
|
+
budgetId: str
|
|
116
|
+
goalId: str
|
|
117
|
+
totalBudgetUsdc: str
|
|
118
|
+
spentUsdc: str
|
|
119
|
+
reservedUsdc: str
|
|
120
|
+
remainingUsdc: str
|
|
121
|
+
spend_entries: list[ComputeBudgetSpendEntry]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ComputeCancelResult(TypedDict, total=False):
|
|
125
|
+
"""Returned by `cancel_compute_goal()`."""
|
|
126
|
+
|
|
127
|
+
goal_id: str
|
|
128
|
+
status: str
|
|
129
|
+
remaining_budget: str
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ComputeTaskResponseResult(TypedDict, total=False):
|
|
133
|
+
"""Returned by `respond_to_compute_task()`."""
|
|
134
|
+
|
|
135
|
+
task_id: str
|
|
136
|
+
goal_id: str
|
|
137
|
+
task_status: str
|
|
138
|
+
orchestrator_action: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ComputePauseResult(TypedDict, total=False):
|
|
142
|
+
"""Returned by `pause_compute_goal()`."""
|
|
143
|
+
|
|
144
|
+
goal_id: str
|
|
145
|
+
status: str
|
|
146
|
+
run_count: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ComputeResumeResult(TypedDict, total=False):
|
|
150
|
+
"""Returned by `resume_compute_goal()`."""
|
|
151
|
+
|
|
152
|
+
goal_id: str
|
|
153
|
+
status: str
|
|
154
|
+
next_run_at: str
|
|
155
|
+
run_count: int
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ComputeFundResult(TypedDict, total=False):
|
|
159
|
+
"""Returned by `fund_compute_goal()`."""
|
|
160
|
+
|
|
161
|
+
goal_id: str
|
|
162
|
+
topped_up: float
|
|
163
|
+
total_budget: str
|
|
164
|
+
remaining: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"ComputeSchedule",
|
|
169
|
+
"ComputeGoalResult",
|
|
170
|
+
"ComputeGoalStatus",
|
|
171
|
+
"ComputeTask",
|
|
172
|
+
"ComputeBudgetStatus",
|
|
173
|
+
"ComputeCancelResult",
|
|
174
|
+
"ComputeTaskResponseResult",
|
|
175
|
+
"ComputePauseResult",
|
|
176
|
+
"ComputeResumeResult",
|
|
177
|
+
"ComputeFundResult",
|
|
178
|
+
]
|
|
@@ -46,29 +46,44 @@ _MAX_POLL_RETRIES = 3
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _build_email_payload(
|
|
49
|
-
to: str,
|
|
50
|
-
subject: str,
|
|
49
|
+
to: Optional[str],
|
|
50
|
+
subject: Optional[str],
|
|
51
51
|
body: str,
|
|
52
52
|
from_domain: Optional[str],
|
|
53
53
|
from_mailbox: Optional[str],
|
|
54
54
|
from_name: Optional[str],
|
|
55
55
|
extra: dict[str, Any],
|
|
56
|
+
*,
|
|
57
|
+
reply_to_email_id: Optional[str] = None,
|
|
56
58
|
) -> dict[str, Any]:
|
|
57
59
|
"""Build the /v1/tools/email/send body in the API's field convention.
|
|
58
60
|
|
|
59
61
|
The API expects ``from_address`` + ``to_address`` (see
|
|
60
62
|
EmailSendRequestSchema). Construct from_address from the mailbox/domain
|
|
61
63
|
and forward the optional display name.
|
|
64
|
+
|
|
65
|
+
When ``reply_to_email_id`` is set (threaded reply to an inbound email),
|
|
66
|
+
``to``/``subject`` may be omitted — the server derives them from the
|
|
67
|
+
inbound message; otherwise they are required.
|
|
62
68
|
"""
|
|
69
|
+
if not reply_to_email_id:
|
|
70
|
+
if not to:
|
|
71
|
+
raise ValidationError("to is required unless reply_to_email_id is set", "to")
|
|
72
|
+
if not subject:
|
|
73
|
+
raise ValidationError("subject is required unless reply_to_email_id is set", "subject")
|
|
63
74
|
mailbox = from_mailbox or "agent"
|
|
64
75
|
domain = from_domain or "oneshotagent.com"
|
|
65
76
|
payload: dict[str, Any] = {
|
|
66
77
|
"from_address": f"{mailbox}@{domain}",
|
|
67
|
-
"to_address": to,
|
|
68
|
-
"subject": subject,
|
|
69
78
|
"body": body,
|
|
70
79
|
**extra,
|
|
71
80
|
}
|
|
81
|
+
if to is not None:
|
|
82
|
+
payload["to_address"] = to
|
|
83
|
+
if subject is not None:
|
|
84
|
+
payload["subject"] = subject
|
|
85
|
+
if reply_to_email_id:
|
|
86
|
+
payload["reply_to_email_id"] = reply_to_email_id
|
|
72
87
|
if from_name:
|
|
73
88
|
payload["from_name"] = from_name
|
|
74
89
|
return payload
|
|
@@ -417,6 +432,207 @@ class OneShotClient:
|
|
|
417
432
|
return {"success": True}
|
|
418
433
|
return resp.json()
|
|
419
434
|
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
# Compute — autonomous goal orchestration (mirrors TS SDK)
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
# Two paid endpoints (compute + fund) reuse `acall_tool` for the full
|
|
439
|
+
# x402 quote+pay flow. The seven free endpoints are one-line wrappers
|
|
440
|
+
# around `acall_free_get / post` + the `_unwrap_data` envelope strip.
|
|
441
|
+
# Method names mirror the TS camelCase under Python snake_case
|
|
442
|
+
# (`getComputeGoal` -> `get_compute_goal`).
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _unwrap_data(envelope: Any) -> Any:
|
|
446
|
+
"""Strip the `{"success": true, "data": …}` envelope the compute
|
|
447
|
+
endpoints return — matches the TS SDK convention so callers see the
|
|
448
|
+
inner shape directly. `acall_tool` already unwraps for paid endpoints;
|
|
449
|
+
`acall_free_get / post` do not, so this helper handles them here."""
|
|
450
|
+
if isinstance(envelope, dict) and "data" in envelope:
|
|
451
|
+
return envelope["data"]
|
|
452
|
+
return envelope
|
|
453
|
+
|
|
454
|
+
def compute(
|
|
455
|
+
self,
|
|
456
|
+
objective: str,
|
|
457
|
+
*,
|
|
458
|
+
params: Optional[dict[str, Any]] = None,
|
|
459
|
+
budget_usdc: Optional[float] = None,
|
|
460
|
+
deadline: Optional[str] = None,
|
|
461
|
+
soul_slug: Optional[str] = None,
|
|
462
|
+
soul_service_slug: Optional[str] = None,
|
|
463
|
+
schedule: Optional[dict[str, Any]] = None,
|
|
464
|
+
max_cost: Optional[float] = None,
|
|
465
|
+
memo: Optional[str] = None,
|
|
466
|
+
) -> Any:
|
|
467
|
+
"""Create a compute goal. Blocking."""
|
|
468
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
469
|
+
self.acompute(
|
|
470
|
+
objective,
|
|
471
|
+
params=params,
|
|
472
|
+
budget_usdc=budget_usdc,
|
|
473
|
+
deadline=deadline,
|
|
474
|
+
soul_slug=soul_slug,
|
|
475
|
+
soul_service_slug=soul_service_slug,
|
|
476
|
+
schedule=schedule,
|
|
477
|
+
max_cost=max_cost,
|
|
478
|
+
memo=memo,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def acompute(
|
|
483
|
+
self,
|
|
484
|
+
objective: str,
|
|
485
|
+
*,
|
|
486
|
+
params: Optional[dict[str, Any]] = None,
|
|
487
|
+
budget_usdc: Optional[float] = None,
|
|
488
|
+
deadline: Optional[str] = None,
|
|
489
|
+
soul_slug: Optional[str] = None,
|
|
490
|
+
soul_service_slug: Optional[str] = None,
|
|
491
|
+
schedule: Optional[dict[str, Any]] = None,
|
|
492
|
+
max_cost: Optional[float] = None,
|
|
493
|
+
memo: Optional[str] = None,
|
|
494
|
+
) -> Any:
|
|
495
|
+
"""Create a compute goal — the orchestrator plans + executes autonomously.
|
|
496
|
+
|
|
497
|
+
Quote+pay (x402) flow handled by `acall_tool`. Returns the
|
|
498
|
+
``data`` block of the 202 response (`goal_id`, `status`, …).
|
|
499
|
+
"""
|
|
500
|
+
payload: dict[str, Any] = {"objective": objective}
|
|
501
|
+
if params is not None:
|
|
502
|
+
payload["params"] = params
|
|
503
|
+
if budget_usdc is not None:
|
|
504
|
+
payload["budget_usdc"] = budget_usdc
|
|
505
|
+
if deadline is not None:
|
|
506
|
+
payload["deadline"] = deadline
|
|
507
|
+
if soul_slug is not None:
|
|
508
|
+
payload["soul_slug"] = soul_slug
|
|
509
|
+
if soul_service_slug is not None:
|
|
510
|
+
payload["soul_service_slug"] = soul_service_slug
|
|
511
|
+
if schedule is not None:
|
|
512
|
+
payload["schedule"] = schedule
|
|
513
|
+
if memo is not None:
|
|
514
|
+
payload["memo"] = memo
|
|
515
|
+
return await self.acall_tool("/v1/compute", payload, max_cost=max_cost)
|
|
516
|
+
|
|
517
|
+
def get_compute_goal(self, goal_id: str) -> Any:
|
|
518
|
+
"""Get goal status. Blocking."""
|
|
519
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
520
|
+
self.aget_compute_goal(goal_id)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
async def aget_compute_goal(self, goal_id: str) -> Any:
|
|
524
|
+
"""Get goal status. Async."""
|
|
525
|
+
return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}"))
|
|
526
|
+
|
|
527
|
+
def get_compute_tasks(self, goal_id: str) -> Any:
|
|
528
|
+
"""List tasks under a goal. Blocking."""
|
|
529
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
530
|
+
self.aget_compute_tasks(goal_id)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
async def aget_compute_tasks(self, goal_id: str) -> Any:
|
|
534
|
+
"""List tasks under a goal. Async."""
|
|
535
|
+
return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/tasks"))
|
|
536
|
+
|
|
537
|
+
def get_compute_budget(self, goal_id: str) -> Any:
|
|
538
|
+
"""Get budget breakdown. Blocking."""
|
|
539
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
540
|
+
self.aget_compute_budget(goal_id)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
async def aget_compute_budget(self, goal_id: str) -> Any:
|
|
544
|
+
"""Get budget breakdown. Async."""
|
|
545
|
+
return self._unwrap_data(await self.acall_free_get(f"/v1/compute/{goal_id}/budget"))
|
|
546
|
+
|
|
547
|
+
def cancel_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
|
|
548
|
+
"""Cancel a goal; remaining budget is credited. Blocking."""
|
|
549
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
550
|
+
self.acancel_compute_goal(goal_id, reason)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
async def acancel_compute_goal(
|
|
554
|
+
self, goal_id: str, reason: Optional[str] = None
|
|
555
|
+
) -> Any:
|
|
556
|
+
"""Cancel a goal; remaining budget is credited. Async."""
|
|
557
|
+
payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
|
|
558
|
+
return self._unwrap_data(
|
|
559
|
+
await self.acall_free_post(f"/v1/compute/{goal_id}/cancel", payload)
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def respond_to_compute_task(
|
|
563
|
+
self,
|
|
564
|
+
goal_id: str,
|
|
565
|
+
task_id: str,
|
|
566
|
+
*,
|
|
567
|
+
response: Optional[str] = None,
|
|
568
|
+
approved: Optional[bool] = None,
|
|
569
|
+
) -> Any:
|
|
570
|
+
"""Respond to a human-in-the-loop approval task. Blocking."""
|
|
571
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
572
|
+
self.arespond_to_compute_task(
|
|
573
|
+
goal_id, task_id, response=response, approved=approved
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async def arespond_to_compute_task(
|
|
578
|
+
self,
|
|
579
|
+
goal_id: str,
|
|
580
|
+
task_id: str,
|
|
581
|
+
*,
|
|
582
|
+
response: Optional[str] = None,
|
|
583
|
+
approved: Optional[bool] = None,
|
|
584
|
+
) -> Any:
|
|
585
|
+
"""Respond to a human-in-the-loop approval task. Async."""
|
|
586
|
+
payload: dict[str, Any] = {"task_id": task_id}
|
|
587
|
+
if response is not None:
|
|
588
|
+
payload["response"] = response
|
|
589
|
+
if approved is not None:
|
|
590
|
+
payload["approved"] = approved
|
|
591
|
+
return self._unwrap_data(
|
|
592
|
+
await self.acall_free_post(f"/v1/compute/{goal_id}/respond", payload)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def pause_compute_goal(self, goal_id: str, reason: Optional[str] = None) -> Any:
|
|
596
|
+
"""Pause a recurring goal. Blocking."""
|
|
597
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
598
|
+
self.apause_compute_goal(goal_id, reason)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
async def apause_compute_goal(
|
|
602
|
+
self, goal_id: str, reason: Optional[str] = None
|
|
603
|
+
) -> Any:
|
|
604
|
+
"""Pause a recurring goal. Async."""
|
|
605
|
+
payload: dict[str, Any] = {"reason": reason} if reason is not None else {}
|
|
606
|
+
return self._unwrap_data(
|
|
607
|
+
await self.acall_free_post(f"/v1/compute/{goal_id}/pause", payload)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def resume_compute_goal(self, goal_id: str) -> Any:
|
|
611
|
+
"""Resume a paused recurring goal. Blocking."""
|
|
612
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
613
|
+
self.aresume_compute_goal(goal_id)
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
async def aresume_compute_goal(self, goal_id: str) -> Any:
|
|
617
|
+
"""Resume a paused recurring goal. Async."""
|
|
618
|
+
return self._unwrap_data(
|
|
619
|
+
await self.acall_free_post(f"/v1/compute/{goal_id}/resume", {})
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def fund_compute_goal(self, goal_id: str, amount: float) -> Any:
|
|
623
|
+
"""Top up budget on a recurring goal. Blocking."""
|
|
624
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
625
|
+
self.afund_compute_goal(goal_id, amount)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
async def afund_compute_goal(self, goal_id: str, amount: float) -> Any:
|
|
629
|
+
"""Top up budget on a recurring goal. Quote+pay (x402) via `acall_tool`."""
|
|
630
|
+
if not amount or amount <= 0:
|
|
631
|
+
raise ValidationError("amount must be a positive number", "amount")
|
|
632
|
+
return await self.acall_tool(
|
|
633
|
+
f"/v1/compute/{goal_id}/fund", {"amount": amount}
|
|
634
|
+
)
|
|
635
|
+
|
|
420
636
|
# ------------------------------------------------------------------
|
|
421
637
|
# Browser
|
|
422
638
|
# ------------------------------------------------------------------
|
|
@@ -529,36 +745,48 @@ class OneShotClient:
|
|
|
529
745
|
|
|
530
746
|
def email(
|
|
531
747
|
self,
|
|
532
|
-
to: str,
|
|
533
|
-
subject: str,
|
|
534
|
-
body: str,
|
|
748
|
+
to: Optional[str] = None,
|
|
749
|
+
subject: Optional[str] = None,
|
|
750
|
+
body: str = "",
|
|
535
751
|
*,
|
|
536
752
|
from_domain: Optional[str] = None,
|
|
537
753
|
from_mailbox: Optional[str] = None,
|
|
538
754
|
from_name: Optional[str] = None,
|
|
755
|
+
reply_to_email_id: Optional[str] = None,
|
|
539
756
|
**kwargs: Any,
|
|
540
757
|
) -> Any:
|
|
541
|
-
"""Send an email. Blocking.
|
|
758
|
+
"""Send an email. Blocking.
|
|
759
|
+
|
|
760
|
+
Set ``reply_to_email_id`` (an inbound email id from ``inbox()``) to
|
|
761
|
+
reply within a thread — ``to``/``subject`` are then derived from the
|
|
762
|
+
inbound message unless given.
|
|
763
|
+
"""
|
|
542
764
|
return self.call_tool(
|
|
543
765
|
"/v1/tools/email/send",
|
|
544
|
-
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
|
|
766
|
+
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
|
|
545
767
|
)
|
|
546
768
|
|
|
547
769
|
async def aemail(
|
|
548
770
|
self,
|
|
549
|
-
to: str,
|
|
550
|
-
subject: str,
|
|
551
|
-
body: str,
|
|
771
|
+
to: Optional[str] = None,
|
|
772
|
+
subject: Optional[str] = None,
|
|
773
|
+
body: str = "",
|
|
552
774
|
*,
|
|
553
775
|
from_domain: Optional[str] = None,
|
|
554
776
|
from_mailbox: Optional[str] = None,
|
|
555
777
|
from_name: Optional[str] = None,
|
|
778
|
+
reply_to_email_id: Optional[str] = None,
|
|
556
779
|
**kwargs: Any,
|
|
557
780
|
) -> Any:
|
|
558
|
-
"""Send an email. Async.
|
|
781
|
+
"""Send an email. Async.
|
|
782
|
+
|
|
783
|
+
Set ``reply_to_email_id`` (an inbound email id from ``ainbox()``) to
|
|
784
|
+
reply within a thread — ``to``/``subject`` are then derived from the
|
|
785
|
+
inbound message unless given.
|
|
786
|
+
"""
|
|
559
787
|
return await self.acall_tool(
|
|
560
788
|
"/v1/tools/email/send",
|
|
561
|
-
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs),
|
|
789
|
+
_build_email_payload(to, subject, body, from_domain, from_mailbox, from_name, kwargs, reply_to_email_id=reply_to_email_id),
|
|
562
790
|
)
|
|
563
791
|
|
|
564
792
|
def voice(self, objective: str, target_number: str, **kwargs: Any) -> Any:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Tests for OneShotClient.compute() and the eight friends.
|
|
2
|
+
|
|
3
|
+
Mocks the lower-level `call_tool` / `acall_tool` and `call_free_get/post`
|
|
4
|
+
helpers (matching the test_balance.py pattern). The full x402 quote+pay
|
|
5
|
+
flow is exercised by test_x402.py and the existing acall_tool tests; here
|
|
6
|
+
we lock in the compute-specific contract: correct endpoints, correct
|
|
7
|
+
payload shape, correct passthrough of optional kwargs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from unittest.mock import AsyncMock, patch
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from eth_account import Account
|
|
16
|
+
|
|
17
|
+
from oneshot._errors import ValidationError
|
|
18
|
+
from oneshot.client import OneShotClient
|
|
19
|
+
|
|
20
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
21
|
+
TEST_ADDRESS = Account.from_key(TEST_PRIVATE_KEY).address
|
|
22
|
+
|
|
23
|
+
GOAL_ID = "goal_01HXTEST0000000000000000"
|
|
24
|
+
TASK_ID = "task_01HXTEST0000000000000000"
|
|
25
|
+
|
|
26
|
+
MOCK_COMPUTE_RESULT = {
|
|
27
|
+
"goal_id": GOAL_ID,
|
|
28
|
+
"request_id": "req_01HX",
|
|
29
|
+
"status": "pending",
|
|
30
|
+
"message": "Goal created",
|
|
31
|
+
"goal": {"objective": "research X", "budget_usdc": "5.00"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
MOCK_GOAL_STATUS = {
|
|
35
|
+
"id": GOAL_ID,
|
|
36
|
+
"status": "running",
|
|
37
|
+
"name": "Research X",
|
|
38
|
+
"objective": "research X",
|
|
39
|
+
"current_phase": 1,
|
|
40
|
+
"plan": None,
|
|
41
|
+
"budget": {"total": "5.00", "spent": "1.20", "reserved": "0.30", "remaining": "3.50"},
|
|
42
|
+
"soul_agent_id": None,
|
|
43
|
+
"deadline": None,
|
|
44
|
+
"started_at": "2026-06-04T00:00:00Z",
|
|
45
|
+
"completed_at": None,
|
|
46
|
+
"last_wake_at": None,
|
|
47
|
+
"next_wake_at": None,
|
|
48
|
+
"created_at": "2026-06-04T00:00:00Z",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
MOCK_TASKS = [
|
|
52
|
+
{"id": "t1", "task_type": "research", "tool": "research", "description": "…", "status": "completed"},
|
|
53
|
+
{"id": "t2", "task_type": "build", "tool": "build", "description": "…", "status": "pending"},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
MOCK_BUDGET = {
|
|
57
|
+
"budgetId": "bud_01HX",
|
|
58
|
+
"goalId": GOAL_ID,
|
|
59
|
+
"totalBudgetUsdc": "5.00",
|
|
60
|
+
"spentUsdc": "1.20",
|
|
61
|
+
"reservedUsdc": "0.30",
|
|
62
|
+
"remainingUsdc": "3.50",
|
|
63
|
+
"spend_entries": [],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
MOCK_CANCEL = {"goal_id": GOAL_ID, "status": "cancelled", "remaining_budget": "3.50"}
|
|
67
|
+
MOCK_RESPOND = {
|
|
68
|
+
"task_id": TASK_ID,
|
|
69
|
+
"goal_id": GOAL_ID,
|
|
70
|
+
"task_status": "completed",
|
|
71
|
+
"orchestrator_action": "advance",
|
|
72
|
+
}
|
|
73
|
+
MOCK_PAUSE = {"goal_id": GOAL_ID, "status": "paused", "run_count": 4}
|
|
74
|
+
MOCK_RESUME = {"goal_id": GOAL_ID, "status": "running", "next_run_at": "2026-06-05T00:00:00Z", "run_count": 4}
|
|
75
|
+
MOCK_FUND = {"goal_id": GOAL_ID, "topped_up": 10.0, "total_budget": "15.00", "remaining": "13.50"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _client() -> OneShotClient:
|
|
79
|
+
return OneShotClient(TEST_PRIVATE_KEY)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ─── compute() — quote+pay ────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestCompute:
|
|
86
|
+
def test_posts_minimal_payload_to_compute_endpoint(self) -> None:
|
|
87
|
+
client = _client()
|
|
88
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
|
|
89
|
+
result = client.compute(objective="research X")
|
|
90
|
+
mock.assert_called_once_with("/v1/compute", {"objective": "research X"}, max_cost=None)
|
|
91
|
+
assert result == MOCK_COMPUTE_RESULT
|
|
92
|
+
|
|
93
|
+
def test_threads_all_optional_kwargs_into_payload(self) -> None:
|
|
94
|
+
client = _client()
|
|
95
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
|
|
96
|
+
client.compute(
|
|
97
|
+
objective="research X",
|
|
98
|
+
params={"depth": "deep"},
|
|
99
|
+
budget_usdc=5.0,
|
|
100
|
+
deadline="2026-12-31T23:59:59Z",
|
|
101
|
+
soul_slug="agent-x",
|
|
102
|
+
soul_service_slug="svc-x",
|
|
103
|
+
schedule={"cron": "0 0 * * *", "budget_per_run": 1.0},
|
|
104
|
+
max_cost=10.0,
|
|
105
|
+
memo="hello",
|
|
106
|
+
)
|
|
107
|
+
call_args = mock.call_args
|
|
108
|
+
assert call_args.args[0] == "/v1/compute"
|
|
109
|
+
payload = call_args.args[1]
|
|
110
|
+
assert payload["objective"] == "research X"
|
|
111
|
+
assert payload["params"] == {"depth": "deep"}
|
|
112
|
+
assert payload["budget_usdc"] == 5.0
|
|
113
|
+
assert payload["deadline"] == "2026-12-31T23:59:59Z"
|
|
114
|
+
assert payload["soul_slug"] == "agent-x"
|
|
115
|
+
assert payload["soul_service_slug"] == "svc-x"
|
|
116
|
+
assert payload["schedule"] == {"cron": "0 0 * * *", "budget_per_run": 1.0}
|
|
117
|
+
assert payload["memo"] == "hello"
|
|
118
|
+
assert call_args.kwargs["max_cost"] == 10.0
|
|
119
|
+
|
|
120
|
+
def test_omits_none_kwargs_from_payload(self) -> None:
|
|
121
|
+
client = _client()
|
|
122
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
|
|
123
|
+
client.compute(objective="x", budget_usdc=2.0)
|
|
124
|
+
payload = mock.call_args.args[1]
|
|
125
|
+
assert "params" not in payload
|
|
126
|
+
assert "deadline" not in payload
|
|
127
|
+
assert "memo" not in payload
|
|
128
|
+
assert payload["budget_usdc"] == 2.0
|
|
129
|
+
|
|
130
|
+
@pytest.mark.asyncio
|
|
131
|
+
async def test_async_mirror_calls_acall_tool(self) -> None:
|
|
132
|
+
client = _client()
|
|
133
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_COMPUTE_RESULT) as mock:
|
|
134
|
+
result = await client.acompute(objective="x", budget_usdc=1.0)
|
|
135
|
+
mock.assert_awaited_once()
|
|
136
|
+
assert result == MOCK_COMPUTE_RESULT
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─── free read endpoints ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestGetComputeGoal:
|
|
143
|
+
def test_calls_correct_path(self) -> None:
|
|
144
|
+
client = _client()
|
|
145
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_GOAL_STATUS) as mock:
|
|
146
|
+
result = client.get_compute_goal(GOAL_ID)
|
|
147
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}")
|
|
148
|
+
assert result == MOCK_GOAL_STATUS
|
|
149
|
+
|
|
150
|
+
def test_strips_data_envelope(self) -> None:
|
|
151
|
+
"""API returns {success: true, data: {...}}; SDK should unwrap to match TS."""
|
|
152
|
+
client = _client()
|
|
153
|
+
envelope = {"success": True, "data": MOCK_GOAL_STATUS}
|
|
154
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=envelope):
|
|
155
|
+
result = client.get_compute_goal(GOAL_ID)
|
|
156
|
+
assert result == MOCK_GOAL_STATUS
|
|
157
|
+
assert "data" not in result # confirmed unwrap, not pass-through
|
|
158
|
+
|
|
159
|
+
def test_passes_through_when_already_unwrapped(self) -> None:
|
|
160
|
+
"""Some callers (or future API shape changes) may already return the inner
|
|
161
|
+
object. Helper should not double-strip a key called `data`."""
|
|
162
|
+
client = _client()
|
|
163
|
+
inner = {"id": GOAL_ID, "status": "running"}
|
|
164
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=inner):
|
|
165
|
+
result = client.get_compute_goal(GOAL_ID)
|
|
166
|
+
assert result == inner
|
|
167
|
+
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_async_mirror(self) -> None:
|
|
170
|
+
client = _client()
|
|
171
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_GOAL_STATUS):
|
|
172
|
+
result = await client.aget_compute_goal(GOAL_ID)
|
|
173
|
+
assert result["id"] == GOAL_ID
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestGetComputeTasks:
|
|
177
|
+
def test_calls_correct_path(self) -> None:
|
|
178
|
+
client = _client()
|
|
179
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_TASKS) as mock:
|
|
180
|
+
result = client.get_compute_tasks(GOAL_ID)
|
|
181
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/tasks")
|
|
182
|
+
assert result == MOCK_TASKS
|
|
183
|
+
|
|
184
|
+
@pytest.mark.asyncio
|
|
185
|
+
async def test_async_mirror(self) -> None:
|
|
186
|
+
client = _client()
|
|
187
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_TASKS):
|
|
188
|
+
result = await client.aget_compute_tasks(GOAL_ID)
|
|
189
|
+
assert len(result) == 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestGetComputeBudget:
|
|
193
|
+
def test_calls_correct_path(self) -> None:
|
|
194
|
+
client = _client()
|
|
195
|
+
with patch.object(client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_BUDGET) as mock:
|
|
196
|
+
result = client.get_compute_budget(GOAL_ID)
|
|
197
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/budget")
|
|
198
|
+
assert result["remainingUsdc"] == "3.50"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─── free write endpoints ─────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestCancelComputeGoal:
|
|
205
|
+
def test_omits_reason_when_none(self) -> None:
|
|
206
|
+
client = _client()
|
|
207
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL) as mock:
|
|
208
|
+
client.cancel_compute_goal(GOAL_ID)
|
|
209
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/cancel", {})
|
|
210
|
+
|
|
211
|
+
def test_includes_reason_when_provided(self) -> None:
|
|
212
|
+
client = _client()
|
|
213
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL) as mock:
|
|
214
|
+
client.cancel_compute_goal(GOAL_ID, reason="user requested")
|
|
215
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/cancel", {"reason": "user requested"})
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_async_mirror(self) -> None:
|
|
219
|
+
client = _client()
|
|
220
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_CANCEL):
|
|
221
|
+
result = await client.acancel_compute_goal(GOAL_ID, reason="x")
|
|
222
|
+
assert result["status"] == "cancelled"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestRespondToComputeTask:
|
|
226
|
+
def test_threads_task_id_and_optional_fields(self) -> None:
|
|
227
|
+
client = _client()
|
|
228
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESPOND) as mock:
|
|
229
|
+
client.respond_to_compute_task(GOAL_ID, TASK_ID, response="ok", approved=True)
|
|
230
|
+
mock.assert_called_once_with(
|
|
231
|
+
f"/v1/compute/{GOAL_ID}/respond",
|
|
232
|
+
{"task_id": TASK_ID, "response": "ok", "approved": True},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def test_omits_none_optional_fields(self) -> None:
|
|
236
|
+
client = _client()
|
|
237
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESPOND) as mock:
|
|
238
|
+
client.respond_to_compute_task(GOAL_ID, TASK_ID)
|
|
239
|
+
payload = mock.call_args.args[1]
|
|
240
|
+
assert payload == {"task_id": TASK_ID}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestPauseComputeGoal:
|
|
244
|
+
def test_omits_reason_when_none(self) -> None:
|
|
245
|
+
client = _client()
|
|
246
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_PAUSE) as mock:
|
|
247
|
+
client.pause_compute_goal(GOAL_ID)
|
|
248
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/pause", {})
|
|
249
|
+
|
|
250
|
+
def test_includes_reason_when_provided(self) -> None:
|
|
251
|
+
client = _client()
|
|
252
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_PAUSE) as mock:
|
|
253
|
+
client.pause_compute_goal(GOAL_ID, reason="manual")
|
|
254
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/pause", {"reason": "manual"})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TestResumeComputeGoal:
|
|
258
|
+
def test_calls_correct_path_with_empty_payload(self) -> None:
|
|
259
|
+
client = _client()
|
|
260
|
+
with patch.object(client, "acall_free_post", new_callable=AsyncMock, return_value=MOCK_RESUME) as mock:
|
|
261
|
+
client.resume_compute_goal(GOAL_ID)
|
|
262
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/resume", {})
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ─── fund_compute_goal() — quote+pay + validation ─────────────────────
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestFundComputeGoal:
|
|
269
|
+
def test_rejects_zero_amount(self) -> None:
|
|
270
|
+
client = _client()
|
|
271
|
+
with pytest.raises(ValidationError):
|
|
272
|
+
client.fund_compute_goal(GOAL_ID, 0)
|
|
273
|
+
|
|
274
|
+
def test_rejects_negative_amount(self) -> None:
|
|
275
|
+
client = _client()
|
|
276
|
+
with pytest.raises(ValidationError):
|
|
277
|
+
client.fund_compute_goal(GOAL_ID, -1.0)
|
|
278
|
+
|
|
279
|
+
def test_posts_to_fund_endpoint_via_acall_tool(self) -> None:
|
|
280
|
+
client = _client()
|
|
281
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_FUND) as mock:
|
|
282
|
+
result = client.fund_compute_goal(GOAL_ID, 10.0)
|
|
283
|
+
mock.assert_called_once_with(f"/v1/compute/{GOAL_ID}/fund", {"amount": 10.0})
|
|
284
|
+
assert result == MOCK_FUND
|
|
285
|
+
|
|
286
|
+
@pytest.mark.asyncio
|
|
287
|
+
async def test_async_mirror(self) -> None:
|
|
288
|
+
client = _client()
|
|
289
|
+
with patch.object(client, "acall_tool", new_callable=AsyncMock, return_value=MOCK_FUND):
|
|
290
|
+
result = await client.afund_compute_goal(GOAL_ID, 5.0)
|
|
291
|
+
assert result["total_budget"] == "15.00"
|
|
@@ -11,9 +11,11 @@ from unittest.mock import AsyncMock, MagicMock
|
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
13
|
|
|
14
|
+
from oneshot._errors import ValidationError
|
|
14
15
|
from oneshot.client import OneShotClient, _build_email_payload
|
|
15
16
|
|
|
16
17
|
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
18
|
+
REPLY_ID = "4fa85f64-5717-4562-b3fc-2c963f66afa6"
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
# ── _build_email_payload (pure) ───────────────────────────────────────────
|
|
@@ -44,6 +46,39 @@ def test_payload_passes_extra_kwargs():
|
|
|
44
46
|
assert p["wait"] is True
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
# ── reply threading (reply_to_email_id) ───────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def test_reply_payload_omits_to_and_subject_when_derivable():
|
|
52
|
+
# Replying: to/subject may be omitted; server derives them.
|
|
53
|
+
p = _build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
54
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
55
|
+
assert p["body"] == "B"
|
|
56
|
+
assert "to_address" not in p
|
|
57
|
+
assert "subject" not in p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_reply_payload_keeps_explicit_to_and_subject():
|
|
61
|
+
p = _build_email_payload("r@x.com", "Custom", "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
62
|
+
assert p["reply_to_email_id"] == REPLY_ID
|
|
63
|
+
assert p["to_address"] == "r@x.com"
|
|
64
|
+
assert p["subject"] == "Custom"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_non_reply_requires_to():
|
|
68
|
+
with pytest.raises(ValidationError):
|
|
69
|
+
_build_email_payload(None, "S", "B", None, None, None, {})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_non_reply_requires_subject():
|
|
73
|
+
with pytest.raises(ValidationError):
|
|
74
|
+
_build_email_payload("r@x.com", None, "B", None, None, None, {})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_reply_does_not_require_to_or_subject():
|
|
78
|
+
# Should not raise even though to and subject are None.
|
|
79
|
+
_build_email_payload(None, None, "B", None, None, None, {}, reply_to_email_id=REPLY_ID)
|
|
80
|
+
|
|
81
|
+
|
|
47
82
|
# ── email() delegation ────────────────────────────────────────────────────
|
|
48
83
|
|
|
49
84
|
def _client() -> OneShotClient:
|
|
@@ -72,3 +107,20 @@ async def test_aemail_sends_correct_contract():
|
|
|
72
107
|
assert payload["from_address"] == "agent@oneshotagent.com"
|
|
73
108
|
assert payload["to_address"] == "r@x.com"
|
|
74
109
|
assert "from_name" not in payload
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_email_reply_forwards_reply_id_without_to_subject():
|
|
113
|
+
c = _client()
|
|
114
|
+
c.email(reply_to_email_id=REPLY_ID, body="Thanks!")
|
|
115
|
+
endpoint, payload = c.call_tool.call_args[0]
|
|
116
|
+
assert endpoint == "/v1/tools/email/send"
|
|
117
|
+
assert payload["reply_to_email_id"] == REPLY_ID
|
|
118
|
+
assert payload["body"] == "Thanks!"
|
|
119
|
+
assert "to_address" not in payload
|
|
120
|
+
assert "subject" not in payload
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_email_without_to_or_reply_id_raises():
|
|
124
|
+
c = _client()
|
|
125
|
+
with pytest.raises(ValidationError):
|
|
126
|
+
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
|