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.
@@ -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"
@@ -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"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ uagent_testkit = uagent_testkit.plugin
@@ -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.