uagent-testkit 0.1.0__py3-none-any.whl
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.
- uagent_testkit/__init__.py +48 -0
- uagent_testkit/_loop.py +30 -0
- uagent_testkit/_recording.py +130 -0
- uagent_testkit/_storage.py +58 -0
- uagent_testkit/harness.py +396 -0
- uagent_testkit/network.py +201 -0
- uagent_testkit/plugin.py +57 -0
- uagent_testkit-0.1.0.dist-info/METADATA +255 -0
- uagent_testkit-0.1.0.dist-info/RECORD +12 -0
- uagent_testkit-0.1.0.dist-info/WHEEL +4 -0
- uagent_testkit-0.1.0.dist-info/entry_points.txt +2 -0
- uagent_testkit-0.1.0.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""uagent-testkit — test Fetch.ai uAgents without a network, a wallet, or a sleep().
|
|
2
|
+
|
|
3
|
+
from uagent_testkit import harness
|
|
4
|
+
|
|
5
|
+
h = harness(my_agent)
|
|
6
|
+
result = await h.deliver(Ping(text="hi"))
|
|
7
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._loop import ensure_event_loop
|
|
11
|
+
from ._recording import Sent
|
|
12
|
+
from ._storage import InMemoryStore
|
|
13
|
+
from .harness import (
|
|
14
|
+
ANY,
|
|
15
|
+
DEFAULT_SENDER,
|
|
16
|
+
AgentHarness,
|
|
17
|
+
Delivery,
|
|
18
|
+
HandlerNotFound,
|
|
19
|
+
UnverifiedSender,
|
|
20
|
+
harness,
|
|
21
|
+
user_sender,
|
|
22
|
+
)
|
|
23
|
+
from .network import (
|
|
24
|
+
AgentNetwork,
|
|
25
|
+
ConversationTooLong,
|
|
26
|
+
Exchange,
|
|
27
|
+
Transcript,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ANY",
|
|
32
|
+
"AgentHarness",
|
|
33
|
+
"AgentNetwork",
|
|
34
|
+
"ConversationTooLong",
|
|
35
|
+
"DEFAULT_SENDER",
|
|
36
|
+
"Delivery",
|
|
37
|
+
"Exchange",
|
|
38
|
+
"HandlerNotFound",
|
|
39
|
+
"InMemoryStore",
|
|
40
|
+
"Sent",
|
|
41
|
+
"Transcript",
|
|
42
|
+
"UnverifiedSender",
|
|
43
|
+
"ensure_event_loop",
|
|
44
|
+
"harness",
|
|
45
|
+
"user_sender",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
uagent_testkit/_loop.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Keep an event loop available for Agent construction.
|
|
2
|
+
|
|
3
|
+
`Agent.__init__` calls `get_event_loop()` and stores the result, so building an
|
|
4
|
+
agent at import time or inside a synchronous pytest fixture raises
|
|
5
|
+
`RuntimeError: There is no current event loop` on Python 3.12+. Since the canonical
|
|
6
|
+
uAgents idiom is a module-level `agent = Agent(...)`, that would make most real
|
|
7
|
+
agents untestable without restructuring them. We make sure a loop is set instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ensure_event_loop() -> asyncio.AbstractEventLoop:
|
|
16
|
+
"""Return the current event loop, creating and installing one if needed."""
|
|
17
|
+
try:
|
|
18
|
+
return asyncio.get_running_loop()
|
|
19
|
+
except RuntimeError:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
|
24
|
+
except RuntimeError:
|
|
25
|
+
loop = None
|
|
26
|
+
|
|
27
|
+
if loop is None or loop.is_closed():
|
|
28
|
+
loop = asyncio.new_event_loop()
|
|
29
|
+
asyncio.set_event_loop(loop)
|
|
30
|
+
return loop
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Context subclasses that capture outbound messages instead of dispatching them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from uagents import Model
|
|
10
|
+
from uagents.context import ExternalContext, InternalContext
|
|
11
|
+
from uagents_core.types import DeliveryStatus, MsgStatus
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from uagents.types import JsonStr
|
|
15
|
+
|
|
16
|
+
ModelT = TypeVar("ModelT", bound=Model)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Sent:
|
|
21
|
+
"""One message a handler passed to ctx.send()."""
|
|
22
|
+
|
|
23
|
+
destination: str
|
|
24
|
+
schema_digest: str
|
|
25
|
+
body: "JsonStr"
|
|
26
|
+
|
|
27
|
+
def matches(self, model: type[Model]) -> bool:
|
|
28
|
+
return self.schema_digest == Model.build_schema_digest(model)
|
|
29
|
+
|
|
30
|
+
def parse(self, model: type[ModelT]) -> ModelT:
|
|
31
|
+
"""Decode the payload as `model`.
|
|
32
|
+
|
|
33
|
+
Raises if the wire schema digest doesn't match, so a renamed or retyped
|
|
34
|
+
field is caught here rather than surfacing as a confusing attribute error.
|
|
35
|
+
"""
|
|
36
|
+
if not self.matches(model):
|
|
37
|
+
raise AssertionError(
|
|
38
|
+
f"message sent to {self.destination} is not a {model.__name__} "
|
|
39
|
+
f"(schema digest {self.schema_digest})"
|
|
40
|
+
)
|
|
41
|
+
return model.model_validate_json(self.body)
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return f"Sent(to={self.destination!r}, body={self.body})"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _RecordingMixin:
|
|
48
|
+
"""Overrides send_raw so nothing touches the resolver, dispenser or network."""
|
|
49
|
+
|
|
50
|
+
def _init_recording(self) -> None:
|
|
51
|
+
self.sent: list[Sent] = []
|
|
52
|
+
|
|
53
|
+
async def send_raw( # type: ignore[override]
|
|
54
|
+
self,
|
|
55
|
+
destination: str,
|
|
56
|
+
message_schema_digest: str,
|
|
57
|
+
message_body: "JsonStr",
|
|
58
|
+
sync: bool = False,
|
|
59
|
+
wait_for_response: bool = False,
|
|
60
|
+
timeout: int = 5,
|
|
61
|
+
protocol_digest: str | None = None,
|
|
62
|
+
queries: dict[str, Any] | None = None,
|
|
63
|
+
expected_response_digests: set[str] | None = None,
|
|
64
|
+
) -> MsgStatus:
|
|
65
|
+
self.sent.append(
|
|
66
|
+
Sent(
|
|
67
|
+
destination=destination,
|
|
68
|
+
schema_digest=message_schema_digest,
|
|
69
|
+
body=message_body,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
# Mirror the bookkeeping the real context does, so that the framework's own
|
|
73
|
+
# validate_replies() still sees the reply and behaves exactly as in prod.
|
|
74
|
+
self._outbound_messages.setdefault(destination, []).append(
|
|
75
|
+
(message_body, message_schema_digest)
|
|
76
|
+
)
|
|
77
|
+
return MsgStatus(
|
|
78
|
+
status=DeliveryStatus.DELIVERED,
|
|
79
|
+
detail="captured by uagent-testkit",
|
|
80
|
+
destination=destination,
|
|
81
|
+
endpoint="",
|
|
82
|
+
session=self._session,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RecordingExternalContext(_RecordingMixin, ExternalContext):
|
|
87
|
+
"""The context handed to on_message handlers under test."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, *args, **kwargs):
|
|
90
|
+
super().__init__(*args, **kwargs)
|
|
91
|
+
self._init_recording()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RecordingInternalContext(_RecordingMixin, InternalContext):
|
|
95
|
+
"""The context handed to on_interval and on_event handlers under test."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, *args, **kwargs):
|
|
98
|
+
super().__init__(*args, **kwargs)
|
|
99
|
+
self._init_recording()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class LogCapture:
|
|
104
|
+
"""Collects records emitted through the agent's logger during one run."""
|
|
105
|
+
|
|
106
|
+
records: list[logging.LogRecord] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
def messages(self, level: int | None = None) -> list[str]:
|
|
109
|
+
return [
|
|
110
|
+
r.getMessage()
|
|
111
|
+
for r in self.records
|
|
112
|
+
if level is None or r.levelno >= level
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def warnings(self) -> list[str]:
|
|
117
|
+
return self.messages(logging.WARNING)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def errors(self) -> list[str]:
|
|
121
|
+
return self.messages(logging.ERROR)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _ListHandler(logging.Handler):
|
|
125
|
+
def __init__(self, capture: LogCapture):
|
|
126
|
+
super().__init__()
|
|
127
|
+
self._capture = capture
|
|
128
|
+
|
|
129
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
130
|
+
self._capture.records.append(record)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""In-memory storage that stands in for the on-disk KeyValueStore during tests."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from uagents.storage import StorageAPI
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InMemoryStore(StorageAPI):
|
|
9
|
+
"""A StorageAPI backed by a plain dict.
|
|
10
|
+
|
|
11
|
+
The stock KeyValueStore writes a JSON file next to the test run and reloads it
|
|
12
|
+
on the next one, so agent state leaks between tests and between CI jobs. This
|
|
13
|
+
keeps the same interface but throws the state away when the harness does.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, initial: dict[str, Any] | None = None):
|
|
17
|
+
self._data: dict[str, Any] = dict(initial or {})
|
|
18
|
+
|
|
19
|
+
def get(self, key: str) -> Any | None:
|
|
20
|
+
return self._data.get(key)
|
|
21
|
+
|
|
22
|
+
def has(self, key: str) -> bool:
|
|
23
|
+
return key in self._data
|
|
24
|
+
|
|
25
|
+
def set(self, key: str, value: Any) -> None:
|
|
26
|
+
self._data[key] = value
|
|
27
|
+
|
|
28
|
+
def remove(self, key: str) -> None:
|
|
29
|
+
self._data.pop(key, None)
|
|
30
|
+
|
|
31
|
+
def clear(self) -> None:
|
|
32
|
+
self._data.clear()
|
|
33
|
+
|
|
34
|
+
# Mapping-ish sugar so tests can assert without reaching for .get()
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key: str) -> Any:
|
|
37
|
+
if key not in self._data:
|
|
38
|
+
raise KeyError(key)
|
|
39
|
+
return self._data[key]
|
|
40
|
+
|
|
41
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
42
|
+
self._data[key] = value
|
|
43
|
+
|
|
44
|
+
def __contains__(self, key: object) -> bool:
|
|
45
|
+
return key in self._data
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return len(self._data)
|
|
49
|
+
|
|
50
|
+
def __iter__(self):
|
|
51
|
+
return iter(self._data)
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""A copy of the full contents, for snapshot-style assertions."""
|
|
55
|
+
return dict(self._data)
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"InMemoryStore({self._data!r})"
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Drive a uAgent's handlers directly, with no network and no Almanac registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from uagents import Agent, Model
|
|
10
|
+
from uagents.agent import AgentRepresentation
|
|
11
|
+
from uagents.types import MsgInfo
|
|
12
|
+
from uagents_core.identity import Identity, generate_user_address, is_user_address
|
|
13
|
+
|
|
14
|
+
from ._recording import (
|
|
15
|
+
LogCapture,
|
|
16
|
+
RecordingExternalContext,
|
|
17
|
+
RecordingInternalContext,
|
|
18
|
+
Sent,
|
|
19
|
+
_ListHandler,
|
|
20
|
+
)
|
|
21
|
+
from ._storage import InMemoryStore
|
|
22
|
+
|
|
23
|
+
ModelT = TypeVar("ModelT", bound=Model)
|
|
24
|
+
|
|
25
|
+
#: Stand-in peer used when a test doesn't care who sent the message.
|
|
26
|
+
DEFAULT_SENDER = Identity.from_seed("uagent-testkit default sender", 0).address
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Any:
|
|
30
|
+
"""Sentinel meaning 'any destination'. See Delivery.replies."""
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return "ANY"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Pass as `to=` to match messages sent to any destination.
|
|
37
|
+
ANY = _Any()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HandlerNotFound(AssertionError):
|
|
41
|
+
"""Raised when the agent has no handler registered for a message type."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UnverifiedSender(AssertionError):
|
|
45
|
+
"""Raised when a user address targets a handler that requires a verified agent."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Delivery:
|
|
49
|
+
"""What happened when one message was handed to the agent.
|
|
50
|
+
|
|
51
|
+
Wraps the messages the handler sent, anything it logged, and any exception it
|
|
52
|
+
raised. The framework itself catches handler exceptions and writes them to a
|
|
53
|
+
log, which makes a broken handler look like a passing test; the harness keeps
|
|
54
|
+
the exception so it can be re-raised or asserted on.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
sent: list[Sent],
|
|
60
|
+
logs: LogCapture,
|
|
61
|
+
error: BaseException | None,
|
|
62
|
+
received: Model,
|
|
63
|
+
received_digest: str,
|
|
64
|
+
sender: str,
|
|
65
|
+
session: uuid.UUID,
|
|
66
|
+
replies: dict[str, dict[str, type[Model]]] | None,
|
|
67
|
+
):
|
|
68
|
+
self.sent = sent
|
|
69
|
+
self.logs = logs
|
|
70
|
+
self.error = error
|
|
71
|
+
self.received = received
|
|
72
|
+
self.received_digest = received_digest
|
|
73
|
+
self.sender = sender
|
|
74
|
+
self.session = session
|
|
75
|
+
self._replies = replies or {}
|
|
76
|
+
|
|
77
|
+
# -- querying what was sent ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def to(self, destination: str) -> list[Sent]:
|
|
80
|
+
"""Every message sent to one address."""
|
|
81
|
+
return [s for s in self.sent if s.destination == destination]
|
|
82
|
+
|
|
83
|
+
def replies(
|
|
84
|
+
self, model: type[ModelT], *, to: str | None = None
|
|
85
|
+
) -> list[ModelT]:
|
|
86
|
+
"""Messages of type `model` sent back to the original sender.
|
|
87
|
+
|
|
88
|
+
Pass `to` for messages sent to a third party instead, or `to=ANY` for
|
|
89
|
+
every message of that type regardless of destination.
|
|
90
|
+
"""
|
|
91
|
+
target = self.sender if to is None else to
|
|
92
|
+
return [
|
|
93
|
+
s.parse(model)
|
|
94
|
+
for s in self.sent
|
|
95
|
+
if s.matches(model) and (target is ANY or s.destination == target)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def reply(self, model: type[ModelT], *, to: str | None = None) -> ModelT:
|
|
99
|
+
"""The single message of type `model`. Raises unless there is exactly one."""
|
|
100
|
+
found = self.replies(model, to=to)
|
|
101
|
+
target = self.sender if to is None else to
|
|
102
|
+
if not found:
|
|
103
|
+
raise AssertionError(
|
|
104
|
+
f"expected one {model.__name__} to {target}, got none. "
|
|
105
|
+
f"Sent: {self.sent or '(nothing)'}"
|
|
106
|
+
)
|
|
107
|
+
if len(found) > 1:
|
|
108
|
+
raise AssertionError(
|
|
109
|
+
f"expected one {model.__name__} to {target}, got {len(found)}"
|
|
110
|
+
)
|
|
111
|
+
return found[0]
|
|
112
|
+
|
|
113
|
+
# -- assertions ------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def assert_replied_with(
|
|
116
|
+
self, model: type[Model], *, to: str | None = None
|
|
117
|
+
) -> "Delivery":
|
|
118
|
+
self.reply(model, to=to)
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def assert_silent(self) -> "Delivery":
|
|
122
|
+
if self.sent:
|
|
123
|
+
raise AssertionError(f"expected no messages, got {self.sent}")
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def assert_no_errors(self) -> "Delivery":
|
|
127
|
+
if self.error is not None:
|
|
128
|
+
raise AssertionError(f"handler raised {self.error!r}") from self.error
|
|
129
|
+
if self.logs.errors:
|
|
130
|
+
raise AssertionError(f"handler logged errors: {self.logs.errors}")
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def assert_reply_contract(self) -> "Delivery":
|
|
134
|
+
"""Assert the handler honoured the `replies=` set on its @on_message.
|
|
135
|
+
|
|
136
|
+
The framework only logs when a handler silently fails to answer, which is
|
|
137
|
+
easy to miss. Under test it should be a failure.
|
|
138
|
+
"""
|
|
139
|
+
allowed = self._replies.get(self.received_digest)
|
|
140
|
+
if not allowed:
|
|
141
|
+
return self
|
|
142
|
+
sent_digests = {s.schema_digest for s in self.to(self.sender)}
|
|
143
|
+
if not sent_digests & set(allowed):
|
|
144
|
+
expected = sorted(m.__name__ for m in allowed.values())
|
|
145
|
+
raise AssertionError(
|
|
146
|
+
f"handler for {type(self.received).__name__} declares replies "
|
|
147
|
+
f"{expected} but sent none of them to {self.sender}"
|
|
148
|
+
)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def raise_for_error(self) -> "Delivery":
|
|
152
|
+
if self.error is not None:
|
|
153
|
+
raise self.error
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def __repr__(self) -> str:
|
|
157
|
+
return (
|
|
158
|
+
f"<Delivery {type(self.received).__name__} from {self.sender[:16]}… "
|
|
159
|
+
f"sent={len(self.sent)} error={self.error!r}>"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AgentHarness:
|
|
164
|
+
"""A uAgent wired for tests.
|
|
165
|
+
|
|
166
|
+
Swaps the agent's on-disk storage for an in-memory one, merges the internal
|
|
167
|
+
protocol so handlers are resolvable, and routes messages straight to the
|
|
168
|
+
registered handler with a context that records instead of transmitting.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, agent: Agent, *, persist_storage: bool = False):
|
|
172
|
+
self.agent = agent
|
|
173
|
+
if not persist_storage:
|
|
174
|
+
agent._storage = InMemoryStore()
|
|
175
|
+
self._ensure_protocol_included(agent)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _ensure_protocol_included(agent: Agent) -> None:
|
|
179
|
+
"""Merge the agent's internal protocol into its handler tables.
|
|
180
|
+
|
|
181
|
+
Normally Agent.setup() does this, but setup() also starts the Almanac
|
|
182
|
+
registration loop and the network dispenser. include() on its own does
|
|
183
|
+
the routing half with no I/O.
|
|
184
|
+
"""
|
|
185
|
+
if getattr(agent, "_testkit_included", False):
|
|
186
|
+
return
|
|
187
|
+
agent.include(agent._protocol)
|
|
188
|
+
agent._testkit_included = True # type: ignore[attr-defined]
|
|
189
|
+
|
|
190
|
+
# -- properties ------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def address(self) -> str:
|
|
194
|
+
return self.agent.address
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def name(self) -> str:
|
|
198
|
+
return self.agent.name
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def storage(self) -> InMemoryStore:
|
|
202
|
+
return self.agent._storage # type: ignore[return-value]
|
|
203
|
+
|
|
204
|
+
def handles(self, model: type[Model]) -> bool:
|
|
205
|
+
"""Whether a handler is registered for this message type."""
|
|
206
|
+
return Model.build_schema_digest(model) in self.agent._models
|
|
207
|
+
|
|
208
|
+
# -- driving handlers ------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
async def deliver(
|
|
211
|
+
self,
|
|
212
|
+
message: Model,
|
|
213
|
+
*,
|
|
214
|
+
sender: str = DEFAULT_SENDER,
|
|
215
|
+
session: uuid.UUID | None = None,
|
|
216
|
+
raise_errors: bool = True,
|
|
217
|
+
) -> Delivery:
|
|
218
|
+
"""Hand `message` to the agent as though `sender` had transmitted it."""
|
|
219
|
+
digest = Model.build_schema_digest(message)
|
|
220
|
+
model_class = self.agent._models.get(digest)
|
|
221
|
+
if model_class is None:
|
|
222
|
+
raise HandlerNotFound(
|
|
223
|
+
f"{type(message).__name__} has no registered handler on "
|
|
224
|
+
f"agent {self.agent.name!r}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Mirror the framework's own resolution order, including its refusal to
|
|
228
|
+
# hand a user-address message to a handler that requires a verified agent.
|
|
229
|
+
handler = self.agent._unsigned_message_handlers.get(digest)
|
|
230
|
+
if handler is None:
|
|
231
|
+
if is_user_address(sender):
|
|
232
|
+
if digest in self.agent._signed_message_handlers:
|
|
233
|
+
raise UnverifiedSender(
|
|
234
|
+
f"{type(message).__name__} is handled by a verified-only "
|
|
235
|
+
f"handler, but {sender!r} is a user address. In production "
|
|
236
|
+
f"this message is rejected with an ErrorMessage. Use an "
|
|
237
|
+
f"agent address, or set allow_unverified=True on the handler."
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
handler = self.agent._signed_message_handlers.get(digest)
|
|
241
|
+
if handler is None:
|
|
242
|
+
raise HandlerNotFound(
|
|
243
|
+
f"no handler function for {type(message).__name__} on "
|
|
244
|
+
f"agent {self.agent.name!r}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
session = session or uuid.uuid4()
|
|
248
|
+
body = message.model_dump_json()
|
|
249
|
+
ctx = self._external_context(digest, body, sender, session)
|
|
250
|
+
|
|
251
|
+
with self._capture_logs() as logs:
|
|
252
|
+
error: BaseException | None = None
|
|
253
|
+
try:
|
|
254
|
+
await handler(ctx, sender, model_class.model_validate_json(body))
|
|
255
|
+
except BaseException as exc: # noqa: BLE001 - surfaced to the caller
|
|
256
|
+
error = exc
|
|
257
|
+
|
|
258
|
+
delivery = Delivery(
|
|
259
|
+
sent=ctx.sent,
|
|
260
|
+
logs=logs,
|
|
261
|
+
error=error,
|
|
262
|
+
received=message,
|
|
263
|
+
received_digest=digest,
|
|
264
|
+
sender=sender,
|
|
265
|
+
session=session,
|
|
266
|
+
replies=self.agent._replies,
|
|
267
|
+
)
|
|
268
|
+
if raise_errors and error is not None:
|
|
269
|
+
raise error
|
|
270
|
+
return delivery
|
|
271
|
+
|
|
272
|
+
async def tick(self, *, only: str | None = None) -> Delivery:
|
|
273
|
+
"""Run the agent's @on_interval handlers once.
|
|
274
|
+
|
|
275
|
+
Periods are ignored — the point is to exercise the body deterministically
|
|
276
|
+
rather than wait out a wall-clock schedule. `only` selects one handler by
|
|
277
|
+
function name.
|
|
278
|
+
"""
|
|
279
|
+
handlers = [
|
|
280
|
+
(fn, period)
|
|
281
|
+
for fn, period in self.agent._interval_handlers
|
|
282
|
+
if only is None or fn.__name__ == only
|
|
283
|
+
]
|
|
284
|
+
if only is not None and not handlers:
|
|
285
|
+
raise HandlerNotFound(f"no interval handler named {only!r}")
|
|
286
|
+
return await self._run_context_handlers(
|
|
287
|
+
[fn for fn, _ in handlers], label="interval"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
async def startup(self) -> Delivery:
|
|
291
|
+
"""Run the agent's @on_event("startup") handlers."""
|
|
292
|
+
return await self._run_context_handlers(self.agent._on_startup, label="startup")
|
|
293
|
+
|
|
294
|
+
async def shutdown(self) -> Delivery:
|
|
295
|
+
"""Run the agent's @on_event("shutdown") handlers."""
|
|
296
|
+
return await self._run_context_handlers(
|
|
297
|
+
self.agent._on_shutdown, label="shutdown"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# -- internals -------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async def _run_context_handlers(self, handlers, *, label: str) -> Delivery:
|
|
303
|
+
ctx = self._internal_context(uuid.uuid4())
|
|
304
|
+
error: BaseException | None = None
|
|
305
|
+
with self._capture_logs() as logs:
|
|
306
|
+
for fn in handlers:
|
|
307
|
+
try:
|
|
308
|
+
await fn(ctx)
|
|
309
|
+
except BaseException as exc: # noqa: BLE001
|
|
310
|
+
error = exc
|
|
311
|
+
break
|
|
312
|
+
delivery = Delivery(
|
|
313
|
+
sent=ctx.sent,
|
|
314
|
+
logs=logs,
|
|
315
|
+
error=error,
|
|
316
|
+
received=_Trigger(label=label),
|
|
317
|
+
received_digest="",
|
|
318
|
+
sender=self.address,
|
|
319
|
+
session=ctx.session,
|
|
320
|
+
replies=None,
|
|
321
|
+
)
|
|
322
|
+
if error is not None:
|
|
323
|
+
raise error
|
|
324
|
+
return delivery
|
|
325
|
+
|
|
326
|
+
def _representation(self) -> AgentRepresentation:
|
|
327
|
+
agent = self.agent
|
|
328
|
+
return AgentRepresentation(
|
|
329
|
+
address=agent.address,
|
|
330
|
+
name=agent._name,
|
|
331
|
+
identity=agent._identity,
|
|
332
|
+
prefix=agent._prefix,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def _common_context_kwargs(self, session: uuid.UUID) -> dict[str, Any]:
|
|
336
|
+
agent = self.agent
|
|
337
|
+
return {
|
|
338
|
+
"agent": self._representation(),
|
|
339
|
+
"storage": agent._storage,
|
|
340
|
+
"ledger": agent._ledger,
|
|
341
|
+
"resolver": agent._resolver,
|
|
342
|
+
"dispenser": agent._dispenser,
|
|
343
|
+
"logger": agent._logger,
|
|
344
|
+
"session": session,
|
|
345
|
+
"message_history": agent._message_history,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
def _external_context(
|
|
349
|
+
self, digest: str, body: str, sender: str, session: uuid.UUID
|
|
350
|
+
) -> RecordingExternalContext:
|
|
351
|
+
return RecordingExternalContext(
|
|
352
|
+
queries={},
|
|
353
|
+
replies=self.agent._replies,
|
|
354
|
+
message_received=MsgInfo(message=body, sender=sender, schema_digest=digest),
|
|
355
|
+
protocol=self.agent.get_message_protocol(digest),
|
|
356
|
+
**self._common_context_kwargs(session),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _internal_context(self, session: uuid.UUID) -> RecordingInternalContext:
|
|
360
|
+
return RecordingInternalContext(
|
|
361
|
+
interval_messages=self.agent._interval_messages,
|
|
362
|
+
**self._common_context_kwargs(session),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _capture_logs(self):
|
|
366
|
+
return _LogCaptureScope(self.agent._logger)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class _Trigger(Model):
|
|
370
|
+
"""Placeholder 'received message' for interval/startup runs."""
|
|
371
|
+
|
|
372
|
+
label: str
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _LogCaptureScope:
|
|
376
|
+
def __init__(self, logger: logging.Logger):
|
|
377
|
+
self._logger = logger
|
|
378
|
+
self._capture = LogCapture()
|
|
379
|
+
self._handler = _ListHandler(self._capture)
|
|
380
|
+
|
|
381
|
+
def __enter__(self) -> LogCapture:
|
|
382
|
+
self._logger.addHandler(self._handler)
|
|
383
|
+
return self._capture
|
|
384
|
+
|
|
385
|
+
def __exit__(self, *exc_info) -> None:
|
|
386
|
+
self._logger.removeHandler(self._handler)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def harness(agent: Agent, *, persist_storage: bool = False) -> AgentHarness:
|
|
390
|
+
"""Wrap an agent for testing. See AgentHarness."""
|
|
391
|
+
return AgentHarness(agent, persist_storage=persist_storage)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def user_sender() -> str:
|
|
395
|
+
"""A user (non-agent) address, for exercising allow_unverified handlers."""
|
|
396
|
+
return generate_user_address()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Run several agents against each other in-process, with no sockets involved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterable, TypeVar
|
|
8
|
+
|
|
9
|
+
from uagents import Agent, Model
|
|
10
|
+
|
|
11
|
+
from ._recording import Sent
|
|
12
|
+
from .harness import AgentHarness, Delivery, HandlerNotFound
|
|
13
|
+
|
|
14
|
+
ModelT = TypeVar("ModelT", bound=Model)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConversationTooLong(AssertionError):
|
|
18
|
+
"""Raised when agents keep replying past the configured round limit."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Exchange:
|
|
23
|
+
"""One message that actually reached an agent, and what that agent did."""
|
|
24
|
+
|
|
25
|
+
sender: str
|
|
26
|
+
recipient: str
|
|
27
|
+
schema_digest: str
|
|
28
|
+
body: str
|
|
29
|
+
delivery: Delivery
|
|
30
|
+
|
|
31
|
+
def matches(self, model: type[Model]) -> bool:
|
|
32
|
+
return self.schema_digest == Model.build_schema_digest(model)
|
|
33
|
+
|
|
34
|
+
def parse(self, model: type[ModelT]) -> ModelT:
|
|
35
|
+
return model.model_validate_json(self.body)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Transcript:
|
|
39
|
+
"""Everything that happened after one message was injected into the network."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, exchanges: list[Exchange], undelivered: list[Sent]):
|
|
42
|
+
self.exchanges = exchanges
|
|
43
|
+
#: Messages addressed to agents the network doesn't know about.
|
|
44
|
+
self.undelivered = undelivered
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return len(self.exchanges)
|
|
48
|
+
|
|
49
|
+
def __iter__(self):
|
|
50
|
+
return iter(self.exchanges)
|
|
51
|
+
|
|
52
|
+
def of(self, model: type[ModelT]) -> list[ModelT]:
|
|
53
|
+
"""Every message of this type that reached a handler."""
|
|
54
|
+
return [e.parse(model) for e in self.exchanges if e.matches(model)]
|
|
55
|
+
|
|
56
|
+
def to(self, recipient: str) -> list[Exchange]:
|
|
57
|
+
return [e for e in self.exchanges if e.recipient == recipient]
|
|
58
|
+
|
|
59
|
+
def assert_delivered(
|
|
60
|
+
self, model: type[Model], *, to: str | None = None
|
|
61
|
+
) -> "Transcript":
|
|
62
|
+
candidates = self.exchanges if to is None else self.to(to)
|
|
63
|
+
if not any(e.matches(model) for e in candidates):
|
|
64
|
+
where = f" to {to}" if to else ""
|
|
65
|
+
raise AssertionError(
|
|
66
|
+
f"{model.__name__} was never delivered{where}. "
|
|
67
|
+
f"Transcript: {self.summary()}"
|
|
68
|
+
)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def assert_all_delivered(self) -> "Transcript":
|
|
72
|
+
if self.undelivered:
|
|
73
|
+
raise AssertionError(
|
|
74
|
+
f"messages went to unknown addresses: {self.undelivered}"
|
|
75
|
+
)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def summary(self) -> str:
|
|
79
|
+
if not self.exchanges:
|
|
80
|
+
return "(no messages)"
|
|
81
|
+
return " -> ".join(
|
|
82
|
+
f"{e.sender[:10]}…/{e.recipient[:10]}…" for e in self.exchanges
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return f"<Transcript {len(self.exchanges)} exchange(s): {self.summary()}>"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AgentNetwork:
|
|
90
|
+
"""A set of agents that can address each other by their real addresses.
|
|
91
|
+
|
|
92
|
+
Delivery is synchronous and depth-first-by-round, so a conversation replays
|
|
93
|
+
the same way every time — no sleeps, no flakes.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, *agents: Agent | AgentHarness, persist_storage: bool = False):
|
|
97
|
+
self._harnesses: dict[str, AgentHarness] = {}
|
|
98
|
+
for agent in agents:
|
|
99
|
+
self.add(agent, persist_storage=persist_storage)
|
|
100
|
+
|
|
101
|
+
def add(
|
|
102
|
+
self, agent: Agent | AgentHarness, *, persist_storage: bool = False
|
|
103
|
+
) -> AgentHarness:
|
|
104
|
+
h = (
|
|
105
|
+
agent
|
|
106
|
+
if isinstance(agent, AgentHarness)
|
|
107
|
+
else AgentHarness(agent, persist_storage=persist_storage)
|
|
108
|
+
)
|
|
109
|
+
self._harnesses[h.address] = h
|
|
110
|
+
return h
|
|
111
|
+
|
|
112
|
+
def __contains__(self, address: object) -> bool:
|
|
113
|
+
return address in self._harnesses
|
|
114
|
+
|
|
115
|
+
def __iter__(self) -> Iterable[AgentHarness]:
|
|
116
|
+
return iter(self._harnesses.values())
|
|
117
|
+
|
|
118
|
+
def harness(self, agent: Agent | AgentHarness | str) -> AgentHarness:
|
|
119
|
+
address = self._address_of(agent)
|
|
120
|
+
if address not in self._harnesses:
|
|
121
|
+
raise KeyError(f"{address} is not part of this network")
|
|
122
|
+
return self._harnesses[address]
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _address_of(agent: Agent | AgentHarness | str) -> str:
|
|
126
|
+
if isinstance(agent, str):
|
|
127
|
+
return agent
|
|
128
|
+
return agent.address
|
|
129
|
+
|
|
130
|
+
async def send(
|
|
131
|
+
self,
|
|
132
|
+
message: Model,
|
|
133
|
+
*,
|
|
134
|
+
to: Agent | AgentHarness | str,
|
|
135
|
+
sender: Agent | AgentHarness | str | None = None,
|
|
136
|
+
max_rounds: int = 20,
|
|
137
|
+
) -> Transcript:
|
|
138
|
+
"""Inject `message` and let the resulting conversation play out.
|
|
139
|
+
|
|
140
|
+
Every reply addressed to another agent in the network is delivered in
|
|
141
|
+
turn, until nobody has anything left to say.
|
|
142
|
+
"""
|
|
143
|
+
recipient = self._address_of(to)
|
|
144
|
+
from_address = (
|
|
145
|
+
self._address_of(sender) if sender is not None else _EXTERNAL_SENDER
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
queue: deque[tuple[str, str, Model | str, str]] = deque()
|
|
149
|
+
queue.append((from_address, recipient, message, Model.build_schema_digest(message)))
|
|
150
|
+
|
|
151
|
+
exchanges: list[Exchange] = []
|
|
152
|
+
undelivered: list[Sent] = []
|
|
153
|
+
|
|
154
|
+
while queue:
|
|
155
|
+
if len(exchanges) >= max_rounds:
|
|
156
|
+
raise ConversationTooLong(
|
|
157
|
+
f"conversation exceeded {max_rounds} deliveries; "
|
|
158
|
+
f"suspected reply loop. So far: {Transcript(exchanges, []).summary()}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
src, dst, payload, digest = queue.popleft()
|
|
162
|
+
harness = self._harnesses.get(dst)
|
|
163
|
+
if harness is None:
|
|
164
|
+
# Shouldn't happen for queued items, but keeps the loop total.
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
model = payload if isinstance(payload, Model) else None
|
|
168
|
+
if model is None:
|
|
169
|
+
model_class = harness.agent._models.get(digest)
|
|
170
|
+
if model_class is None:
|
|
171
|
+
undelivered.append(Sent(dst, digest, payload)) # type: ignore[arg-type]
|
|
172
|
+
continue
|
|
173
|
+
model = model_class.model_validate_json(payload) # type: ignore[arg-type]
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
delivery = await harness.deliver(model, sender=src)
|
|
177
|
+
except HandlerNotFound:
|
|
178
|
+
undelivered.append(Sent(dst, digest, model.model_dump_json()))
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
exchanges.append(
|
|
182
|
+
Exchange(
|
|
183
|
+
sender=src,
|
|
184
|
+
recipient=dst,
|
|
185
|
+
schema_digest=digest,
|
|
186
|
+
body=model.model_dump_json(),
|
|
187
|
+
delivery=delivery,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
for s in delivery.sent:
|
|
192
|
+
if s.destination in self._harnesses:
|
|
193
|
+
queue.append((dst, s.destination, s.body, s.schema_digest))
|
|
194
|
+
else:
|
|
195
|
+
undelivered.append(s)
|
|
196
|
+
|
|
197
|
+
return Transcript(exchanges, undelivered)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
#: Address used when a message is injected from outside the network.
|
|
201
|
+
_EXTERNAL_SENDER = "agent1q0000000000000000000000000000000000000000000000000000000000"
|
uagent_testkit/plugin.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""pytest fixtures. Enabled automatically once the package is installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from ._loop import ensure_event_loop
|
|
8
|
+
from .harness import AgentHarness, harness as _harness
|
|
9
|
+
from .network import AgentNetwork
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
13
|
+
"""Install an event loop before collection.
|
|
14
|
+
|
|
15
|
+
Test modules routinely define `agent = Agent(...)` at module scope, which needs
|
|
16
|
+
a current event loop at import time.
|
|
17
|
+
"""
|
|
18
|
+
ensure_event_loop()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def _uagent_event_loop():
|
|
23
|
+
"""Re-establish a loop for each test.
|
|
24
|
+
|
|
25
|
+
pytest-asyncio closes its loop after every test; without this, a synchronous
|
|
26
|
+
fixture that constructs an Agent fails from the second test onward.
|
|
27
|
+
"""
|
|
28
|
+
ensure_event_loop()
|
|
29
|
+
yield
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def agent_harness():
|
|
34
|
+
"""Factory fixture wrapping an Agent for testing.
|
|
35
|
+
|
|
36
|
+
def test_reply(agent_harness):
|
|
37
|
+
h = agent_harness(my_agent)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def _factory(agent, *, persist_storage: bool = False) -> AgentHarness:
|
|
41
|
+
return _harness(agent, persist_storage=persist_storage)
|
|
42
|
+
|
|
43
|
+
return _factory
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def agent_network():
|
|
48
|
+
"""Factory fixture building an AgentNetwork from agents or harnesses.
|
|
49
|
+
|
|
50
|
+
def test_conversation(agent_network):
|
|
51
|
+
net = agent_network(alice, bob)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def _factory(*agents, persist_storage: bool = False) -> AgentNetwork:
|
|
55
|
+
return AgentNetwork(*agents, persist_storage=persist_storage)
|
|
56
|
+
|
|
57
|
+
return _factory
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uagent-testkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Test Fetch.ai uAgents without a network, a wallet, or a sleep().
|
|
5
|
+
Project-URL: Homepage, https://github.com/pgalyen1987/fetchai_harness
|
|
6
|
+
Project-URL: Repository, https://github.com/pgalyen1987/fetchai_harness
|
|
7
|
+
Project-URL: Issues, https://github.com/pgalyen1987/fetchai_harness/issues
|
|
8
|
+
Author-email: pgalyen1987 <pgalyen87@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentverse,asi,fetchai,pytest,testing,uagents
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: uagents>=0.22
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# uagent-testkit
|
|
29
|
+
|
|
30
|
+
Test [Fetch.ai uAgents](https://github.com/fetchai/uAgents) without a network, a wallet, or a `sleep()`.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from uagent_testkit import harness
|
|
34
|
+
|
|
35
|
+
async def test_echo():
|
|
36
|
+
h = harness(my_agent)
|
|
37
|
+
result = await h.deliver(Ping(text="hi"))
|
|
38
|
+
|
|
39
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
40
|
+
assert h.storage["seen"] == 1
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
The uAgents framework has no testing story. To exercise a handler today you generally
|
|
46
|
+
have to stand the agent up for real — which means Almanac registration, endpoint
|
|
47
|
+
resolution, a funded wallet on some paths, and `asyncio.sleep()` calls sprinkled through
|
|
48
|
+
the test to wait for messages that may never arrive. That is slow, flaky, and awkward to
|
|
49
|
+
run in CI.
|
|
50
|
+
|
|
51
|
+
Worse, `Agent._handle_message` catches every exception a handler raises and writes it to
|
|
52
|
+
a log. A handler that crashes on every message still looks like a green test. The most
|
|
53
|
+
common agent bug is invisible to the most common agent test.
|
|
54
|
+
|
|
55
|
+
`uagent-testkit` routes messages straight to the registered handler with a context that
|
|
56
|
+
records instead of transmitting. No sockets, no registration, no waiting.
|
|
57
|
+
|
|
58
|
+
- **Handler exceptions are raised**, not swallowed into a log.
|
|
59
|
+
- **Storage is in-memory**, so state doesn't leak between tests via the JSON file the
|
|
60
|
+
stock `KeyValueStore` writes to your working directory.
|
|
61
|
+
- **Reply contracts are enforced** — if `@on_message(replies=Pong)` doesn't send a
|
|
62
|
+
`Pong`, that's an assertion failure instead of a log line nobody reads.
|
|
63
|
+
- **Interval and lifecycle handlers run on demand**, so an `@on_interval(period=3600)`
|
|
64
|
+
is testable without waiting an hour.
|
|
65
|
+
- **Multi-agent conversations replay deterministically**, with a loop guard instead of
|
|
66
|
+
a hung test.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install uagent-testkit
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Requires Python 3.10+ and `uagents>=0.22`. The pytest fixtures register automatically.
|
|
75
|
+
|
|
76
|
+
## Testing one agent
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from uagents import Agent, Context, Model
|
|
80
|
+
from uagent_testkit import harness
|
|
81
|
+
|
|
82
|
+
class Ping(Model):
|
|
83
|
+
text: str
|
|
84
|
+
|
|
85
|
+
class Pong(Model):
|
|
86
|
+
text: str
|
|
87
|
+
|
|
88
|
+
agent = Agent(name="echo", seed="echo seed")
|
|
89
|
+
|
|
90
|
+
@agent.on_message(model=Ping, replies=Pong)
|
|
91
|
+
async def on_ping(ctx: Context, sender: str, msg: Ping):
|
|
92
|
+
ctx.storage.set("seen", (ctx.storage.get("seen") or 0) + 1)
|
|
93
|
+
await ctx.send(sender, Pong(text=f"pong:{msg.text}"))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
async def test_ping():
|
|
98
|
+
h = harness(agent)
|
|
99
|
+
result = await h.deliver(Ping(text="hi"), sender="agent1qexample")
|
|
100
|
+
|
|
101
|
+
result.assert_replied_with(Pong)
|
|
102
|
+
result.assert_reply_contract() # honoured its replies= declaration
|
|
103
|
+
assert result.reply(Pong).text == "pong:hi"
|
|
104
|
+
assert result.sent[0].destination == "agent1qexample"
|
|
105
|
+
assert h.storage["seen"] == 1
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Asserting on failure
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
async def test_bad_input_is_handled():
|
|
112
|
+
result = await h.deliver(Ping(text=""), raise_errors=False)
|
|
113
|
+
assert isinstance(result.error, ValueError)
|
|
114
|
+
assert "empty" in result.logs.errors[0]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
By default `deliver()` re-raises whatever the handler raised, so a broken handler fails
|
|
118
|
+
the test loudly. Pass `raise_errors=False` when the failure is the thing under test.
|
|
119
|
+
|
|
120
|
+
### Sender verification
|
|
121
|
+
|
|
122
|
+
Handlers without `allow_unverified=True` are only reachable from a verified agent
|
|
123
|
+
address. The harness enforces that, so a test can't pass for a flow production
|
|
124
|
+
rejects:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from uagent_testkit import UnverifiedSender, user_sender
|
|
128
|
+
|
|
129
|
+
async def test_strict_handler_refuses_users():
|
|
130
|
+
with pytest.raises(UnverifiedSender):
|
|
131
|
+
await h.deliver(Ping(text="hi"), sender=user_sender())
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Intervals and lifecycle
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
await h.startup() # runs @on_event("startup") handlers
|
|
138
|
+
result = await h.tick() # runs @on_interval handlers once, ignoring period
|
|
139
|
+
result = await h.tick(only="heartbeat") # just one, by function name
|
|
140
|
+
await h.shutdown()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Testing agents against each other
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from uagent_testkit import AgentNetwork
|
|
147
|
+
|
|
148
|
+
async def test_conversation():
|
|
149
|
+
net = AgentNetwork(alice, bob)
|
|
150
|
+
transcript = await net.send(Ping(text="hello"), to=bob, sender=alice)
|
|
151
|
+
|
|
152
|
+
transcript.assert_delivered(Pong, to=alice.address)
|
|
153
|
+
assert transcript.of(Pong)[0].text == "pong:hello"
|
|
154
|
+
assert net.harness(alice).storage["last_pong"] == "pong:hello"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Every reply addressed to another agent in the network is delivered in turn until the
|
|
158
|
+
conversation goes quiet. Messages sent to addresses outside the network are collected on
|
|
159
|
+
`transcript.undelivered` rather than silently dropped:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
transcript.assert_all_delivered() # fails if anything went to an unknown address
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Agents that answer each other forever raise `ConversationTooLong` after `max_rounds`
|
|
166
|
+
deliveries instead of hanging the suite. This catches echo loops, which are easy to
|
|
167
|
+
write and expensive to discover in production:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# a keyword responder whose answer contains its own trigger word
|
|
171
|
+
if "menu" in msg.text().lower():
|
|
172
|
+
await ctx.send(sender, chat("here is the menu")) # -> contains "menu"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Two agents like that will talk to each other indefinitely. On a live network that is
|
|
176
|
+
two agents spamming each other; here it fails in milliseconds with the transcript
|
|
177
|
+
so far attached.
|
|
178
|
+
|
|
179
|
+
## Protocols and the chat protocol
|
|
180
|
+
|
|
181
|
+
Agents assembled from `Protocol` objects work the same way, including the shared chat
|
|
182
|
+
protocol from `uagents_core` that Agentverse and Agent Launch agents use:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
chat = Protocol(spec=chat_protocol_spec)
|
|
186
|
+
|
|
187
|
+
@chat.on_message(ChatMessage)
|
|
188
|
+
async def on_chat(ctx: Context, sender: str, msg: ChatMessage):
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
agent.include(chat)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
async def test_chat():
|
|
196
|
+
h = harness(agent)
|
|
197
|
+
result = await h.deliver(
|
|
198
|
+
ChatMessage(
|
|
199
|
+
timestamp=datetime.now(timezone.utc),
|
|
200
|
+
msg_id=uuid4(),
|
|
201
|
+
content=[TextContent(type="text", text="show me the menu")],
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
assert result.replies(ChatAcknowledgement)
|
|
205
|
+
assert result.replies(ChatMessage)[0].text() == "here is what we have"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## pytest fixtures
|
|
209
|
+
|
|
210
|
+
Installing the package registers two factory fixtures:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
def test_with_fixture(agent_harness, agent_network):
|
|
214
|
+
h = agent_harness(my_agent)
|
|
215
|
+
net = agent_network(alice, bob)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## API
|
|
219
|
+
|
|
220
|
+
| | |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `harness(agent)` | Wrap an agent. Swaps in in-memory storage and merges the internal protocol. |
|
|
223
|
+
| `h.deliver(msg, sender=…, raise_errors=True)` | Route a message to its handler. Returns `Delivery`. |
|
|
224
|
+
| `h.tick(only=None)` | Run `@on_interval` handlers once. |
|
|
225
|
+
| `h.startup()` / `h.shutdown()` | Run `@on_event` handlers. |
|
|
226
|
+
| `h.storage` | In-memory store, subscriptable. |
|
|
227
|
+
| `h.handles(Model)` | Whether a handler is registered for a type. |
|
|
228
|
+
| `Delivery.sent` | List of `Sent(destination, schema_digest, body)`. |
|
|
229
|
+
| `Delivery.reply(Model)` | The single reply of that type; raises otherwise. |
|
|
230
|
+
| `Delivery.replies(Model, to=…)` | Replies of that type to the sender; `to=ANY` for any destination. |
|
|
231
|
+
| `user_sender()` | A user (non-agent) address, for `allow_unverified` paths. |
|
|
232
|
+
| `Delivery.error` / `.logs` | Handler exception and captured log records. |
|
|
233
|
+
| `Delivery.assert_replied_with` / `assert_silent` / `assert_no_errors` / `assert_reply_contract` | Chainable assertions. |
|
|
234
|
+
| `AgentNetwork(*agents)` | Multi-agent network. |
|
|
235
|
+
| `net.send(msg, to=…, sender=…, max_rounds=20)` | Play out a conversation. Returns `Transcript`. |
|
|
236
|
+
| `Transcript.of(Model)` / `.to(addr)` / `.undelivered` | Inspect what was exchanged. |
|
|
237
|
+
|
|
238
|
+
## Scope
|
|
239
|
+
|
|
240
|
+
This exercises your handler logic: routing, replies, storage, protocol contracts, and
|
|
241
|
+
inter-agent flow. It deliberately does not simulate Almanac resolution, envelope
|
|
242
|
+
signing, or on-chain settlement — those are integration concerns and mocking them would
|
|
243
|
+
give you false confidence. Keep a thin integration test on a real testnet for those, and
|
|
244
|
+
use this for everything else.
|
|
245
|
+
|
|
246
|
+
## Running the tests
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
python -m venv .venv && .venv/bin/pip install -e ".[test]"
|
|
250
|
+
.venv/bin/python -m pytest
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
Apache-2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
uagent_testkit/__init__.py,sha256=GJXgexig-4xEQlWrgILYYXI8jJbk7Eb33OjOy1JGELw,939
|
|
2
|
+
uagent_testkit/_loop.py,sha256=kvJbue_i_VLJE96TjHsjXFwjmWmA6rYGWqVP8uLxSXI,985
|
|
3
|
+
uagent_testkit/_recording.py,sha256=lNTUp2heOF4PXenzlQ4xXYwufgvDyYwsidMrnzRCsEg,4027
|
|
4
|
+
uagent_testkit/_storage.py,sha256=CyZa2FECI2wcEsrIzYdvCwRJJESEimg65r8ScZqP_n4,1698
|
|
5
|
+
uagent_testkit/harness.py,sha256=XR49G0tlXM9QlTr74eahTFSAGm33SgCxvLhP7CONtUI,13897
|
|
6
|
+
uagent_testkit/network.py,sha256=CkoW9FG6QbJ3r1gLoZMKHEu6OTVfUSbYcdujRrouZ-Y,6893
|
|
7
|
+
uagent_testkit/plugin.py,sha256=bXYm116O8ksjyY4Y8eplqduT4RbDMLVrHX7wYY4xoA0,1531
|
|
8
|
+
uagent_testkit-0.1.0.dist-info/METADATA,sha256=NLl_kLG1y9uJyyskPmf4XFH8kyBN9zB21AoqjwKRa2w,9084
|
|
9
|
+
uagent_testkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
uagent_testkit-0.1.0.dist-info/entry_points.txt,sha256=MQ7SakC1W1ToZa2RXCNsx3Z9UH0vCjGKrNXz6nDTc7A,50
|
|
11
|
+
uagent_testkit-0.1.0.dist-info/licenses/LICENSE,sha256=fs2M4dMLiqJiMvXHyHjMpTvCc1R85S8WePlVFUdG5k8,709
|
|
12
|
+
uagent_testkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|