agent-relay 3.2.2 → 3.2.4

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.
Files changed (246) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +1358 -941
  6. package/dist/src/cli/commands/agent-management.d.ts +2 -2
  7. package/dist/src/cli/commands/agent-management.d.ts.map +1 -1
  8. package/dist/src/cli/commands/agent-management.js +41 -240
  9. package/dist/src/cli/commands/agent-management.js.map +1 -1
  10. package/dist/src/cli/commands/messaging.d.ts +1 -1
  11. package/dist/src/cli/commands/messaging.d.ts.map +1 -1
  12. package/dist/src/cli/commands/messaging.js +14 -5
  13. package/dist/src/cli/commands/messaging.js.map +1 -1
  14. package/dist/src/cli/lib/agent-management-listing.d.ts +4 -1
  15. package/dist/src/cli/lib/agent-management-listing.d.ts.map +1 -1
  16. package/dist/src/cli/lib/agent-management-listing.js +27 -2
  17. package/dist/src/cli/lib/agent-management-listing.js.map +1 -1
  18. package/package.json +11 -10
  19. package/packages/acp-bridge/package.json +2 -2
  20. package/packages/config/package.json +1 -1
  21. package/packages/hooks/package.json +4 -4
  22. package/packages/memory/package.json +2 -2
  23. package/packages/openclaw/package.json +2 -2
  24. package/packages/policy/package.json +2 -2
  25. package/packages/sdk/ADAPTER_REVIEW.md +109 -0
  26. package/packages/sdk/dist/client.d.ts +66 -0
  27. package/packages/sdk/dist/client.d.ts.map +1 -1
  28. package/packages/sdk/dist/client.js +230 -0
  29. package/packages/sdk/dist/client.js.map +1 -1
  30. package/packages/sdk/dist/communicate/a2a-bridge.d.ts +25 -0
  31. package/packages/sdk/dist/communicate/a2a-bridge.d.ts.map +1 -0
  32. package/packages/sdk/dist/communicate/a2a-bridge.js +89 -0
  33. package/packages/sdk/dist/communicate/a2a-bridge.js.map +1 -0
  34. package/packages/sdk/dist/communicate/a2a-server.d.ts +31 -0
  35. package/packages/sdk/dist/communicate/a2a-server.d.ts.map +1 -0
  36. package/packages/sdk/dist/communicate/a2a-server.js +220 -0
  37. package/packages/sdk/dist/communicate/a2a-server.js.map +1 -0
  38. package/packages/sdk/dist/communicate/a2a-transport.d.ts +48 -0
  39. package/packages/sdk/dist/communicate/a2a-transport.d.ts.map +1 -0
  40. package/packages/sdk/dist/communicate/a2a-transport.js +302 -0
  41. package/packages/sdk/dist/communicate/a2a-transport.js.map +1 -0
  42. package/packages/sdk/dist/communicate/a2a-types.d.ts +107 -0
  43. package/packages/sdk/dist/communicate/a2a-types.d.ts.map +1 -0
  44. package/packages/sdk/dist/communicate/a2a-types.js +209 -0
  45. package/packages/sdk/dist/communicate/a2a-types.js.map +1 -0
  46. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts +28 -0
  47. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts.map +1 -0
  48. package/packages/sdk/dist/communicate/adapters/claude-sdk.js +47 -0
  49. package/packages/sdk/dist/communicate/adapters/claude-sdk.js.map +1 -0
  50. package/packages/sdk/dist/communicate/adapters/crewai.d.ts +42 -0
  51. package/packages/sdk/dist/communicate/adapters/crewai.d.ts.map +1 -0
  52. package/packages/sdk/dist/communicate/adapters/crewai.js +95 -0
  53. package/packages/sdk/dist/communicate/adapters/crewai.js.map +1 -0
  54. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts +53 -0
  55. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts.map +1 -0
  56. package/packages/sdk/dist/communicate/adapters/google-adk.js +77 -0
  57. package/packages/sdk/dist/communicate/adapters/google-adk.js.map +1 -0
  58. package/packages/sdk/dist/communicate/adapters/index.d.ts +7 -0
  59. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -0
  60. package/packages/sdk/dist/communicate/adapters/index.js +7 -0
  61. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -0
  62. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts +40 -0
  63. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts.map +1 -0
  64. package/packages/sdk/dist/communicate/adapters/langgraph.js +77 -0
  65. package/packages/sdk/dist/communicate/adapters/langgraph.js.map +1 -0
  66. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts +25 -0
  67. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts.map +1 -0
  68. package/packages/sdk/dist/communicate/adapters/openai-agents.js +70 -0
  69. package/packages/sdk/dist/communicate/adapters/openai-agents.js.map +1 -0
  70. package/packages/sdk/dist/communicate/adapters/pi.d.ts +45 -0
  71. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -0
  72. package/packages/sdk/dist/communicate/adapters/pi.js +59 -0
  73. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -0
  74. package/packages/sdk/dist/communicate/core.d.ts +58 -0
  75. package/packages/sdk/dist/communicate/core.d.ts.map +1 -0
  76. package/packages/sdk/dist/communicate/core.js +128 -0
  77. package/packages/sdk/dist/communicate/core.js.map +1 -0
  78. package/packages/sdk/dist/communicate/index.d.ts +4 -0
  79. package/packages/sdk/dist/communicate/index.d.ts.map +1 -0
  80. package/packages/sdk/dist/communicate/index.js +4 -0
  81. package/packages/sdk/dist/communicate/index.js.map +1 -0
  82. package/packages/sdk/dist/communicate/transport.d.ts +36 -0
  83. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -0
  84. package/packages/sdk/dist/communicate/transport.js +371 -0
  85. package/packages/sdk/dist/communicate/transport.js.map +1 -0
  86. package/packages/sdk/dist/communicate/types.d.ts +58 -0
  87. package/packages/sdk/dist/communicate/types.d.ts.map +1 -0
  88. package/packages/sdk/dist/communicate/types.js +66 -0
  89. package/packages/sdk/dist/communicate/types.js.map +1 -0
  90. package/packages/sdk/dist/workflows/builder.d.ts +35 -5
  91. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  92. package/packages/sdk/dist/workflows/builder.js +81 -7
  93. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  94. package/packages/sdk/dist/workflows/cli.js +14 -1
  95. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  96. package/packages/sdk/dist/workflows/runner.d.ts +10 -2
  97. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  98. package/packages/sdk/dist/workflows/runner.js +95 -1
  99. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  100. package/packages/sdk/dist/workflows/types.d.ts +11 -0
  101. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  102. package/packages/sdk/examples/communicate/claude_sdk_example.ts +5 -0
  103. package/packages/sdk/examples/communicate/pi_example.ts +8 -0
  104. package/packages/sdk/package.json +48 -2
  105. package/packages/sdk/src/__tests__/builder-deterministic.test.ts +132 -0
  106. package/packages/sdk/src/__tests__/communicate/a2a-bridge.test.ts +211 -0
  107. package/packages/sdk/src/__tests__/communicate/a2a-server.test.ts +359 -0
  108. package/packages/sdk/src/__tests__/communicate/a2a-transport.test.ts +537 -0
  109. package/packages/sdk/src/__tests__/communicate/a2a-types.test.ts +297 -0
  110. package/packages/sdk/src/__tests__/communicate/adapters/claude-sdk.test.ts +163 -0
  111. package/packages/sdk/src/__tests__/communicate/adapters/crewai.test.ts +219 -0
  112. package/packages/sdk/src/__tests__/communicate/adapters/e2e-crewai.test.ts +101 -0
  113. package/packages/sdk/src/__tests__/communicate/adapters/e2e-google-adk.test.ts +166 -0
  114. package/packages/sdk/src/__tests__/communicate/adapters/e2e-langgraph.test.ts +181 -0
  115. package/packages/sdk/src/__tests__/communicate/adapters/e2e-openai-agents.test.ts +137 -0
  116. package/packages/sdk/src/__tests__/communicate/adapters/e2e-pi.test.ts +140 -0
  117. package/packages/sdk/src/__tests__/communicate/adapters/google-adk.test.ts +200 -0
  118. package/packages/sdk/src/__tests__/communicate/adapters/langgraph.test.ts +162 -0
  119. package/packages/sdk/src/__tests__/communicate/adapters/openai-agents.test.ts +166 -0
  120. package/packages/sdk/src/__tests__/communicate/adapters/pi.test.ts +140 -0
  121. package/packages/sdk/src/__tests__/communicate/core.test.ts +574 -0
  122. package/packages/sdk/src/__tests__/communicate/integration/cross-framework.test.ts +353 -0
  123. package/packages/sdk/src/__tests__/communicate/transport.test.ts +613 -0
  124. package/packages/sdk/src/__tests__/start-from.test.ts +346 -0
  125. package/packages/sdk/src/client.ts +301 -0
  126. package/packages/sdk/src/communicate/a2a-bridge.ts +111 -0
  127. package/packages/sdk/src/communicate/a2a-server.ts +277 -0
  128. package/packages/sdk/src/communicate/a2a-transport.ts +395 -0
  129. package/packages/sdk/src/communicate/a2a-types.ts +338 -0
  130. package/packages/sdk/src/communicate/adapters/claude-sdk.ts +85 -0
  131. package/packages/sdk/src/communicate/adapters/crewai.ts +141 -0
  132. package/packages/sdk/src/communicate/adapters/google-adk.ts +139 -0
  133. package/packages/sdk/src/communicate/adapters/index.ts +6 -0
  134. package/packages/sdk/src/communicate/adapters/langgraph.ts +112 -0
  135. package/packages/sdk/src/communicate/adapters/openai-agents.ts +113 -0
  136. package/packages/sdk/src/communicate/adapters/pi.ts +105 -0
  137. package/packages/sdk/src/communicate/core.ts +157 -0
  138. package/packages/sdk/src/communicate/index.ts +3 -0
  139. package/packages/sdk/src/communicate/transport.ts +489 -0
  140. package/packages/sdk/src/communicate/types.ts +106 -0
  141. package/packages/sdk/src/examples/workflows/fix-dashboard-user-registration.yaml +182 -0
  142. package/packages/sdk/src/workflows/builder.ts +97 -9
  143. package/packages/sdk/src/workflows/cli.ts +16 -1
  144. package/packages/sdk/src/workflows/runner.ts +110 -1
  145. package/packages/sdk/src/workflows/types.ts +14 -0
  146. package/packages/sdk/tsconfig.build.json +1 -7
  147. package/packages/sdk/tsconfig.json +1 -7
  148. package/packages/sdk-py/README.md +67 -25
  149. package/packages/sdk-py/examples/communicate/agno_example.py +8 -0
  150. package/packages/sdk-py/examples/communicate/claude_sdk_example.py +6 -0
  151. package/packages/sdk-py/examples/communicate/crewai_example.py +7 -0
  152. package/packages/sdk-py/examples/communicate/google_adk_example.py +7 -0
  153. package/packages/sdk-py/examples/communicate/openai_agents_example.py +8 -0
  154. package/packages/sdk-py/examples/communicate/swarms_example.py +7 -0
  155. package/packages/sdk-py/pyproject.toml +12 -1
  156. package/packages/sdk-py/src/agent_relay/__init__.py +8 -0
  157. package/packages/sdk-py/src/agent_relay/builder.py +65 -26
  158. package/packages/sdk-py/src/agent_relay/communicate/__init__.py +6 -0
  159. package/packages/sdk-py/src/agent_relay/communicate/a2a_bridge.py +138 -0
  160. package/packages/sdk-py/src/agent_relay/communicate/a2a_server.py +242 -0
  161. package/packages/sdk-py/src/agent_relay/communicate/a2a_transport.py +366 -0
  162. package/packages/sdk-py/src/agent_relay/communicate/a2a_types.py +294 -0
  163. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +10 -0
  164. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +74 -0
  165. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +78 -0
  166. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +143 -0
  167. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +69 -0
  168. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +86 -0
  169. package/packages/sdk-py/src/agent_relay/communicate/adapters/pi.py +175 -0
  170. package/packages/sdk-py/src/agent_relay/communicate/adapters/swarms.py +44 -0
  171. package/packages/sdk-py/src/agent_relay/communicate/core.py +293 -0
  172. package/packages/sdk-py/src/agent_relay/communicate/transport.py +502 -0
  173. package/packages/sdk-py/src/agent_relay/communicate/types.py +89 -0
  174. package/packages/sdk-py/src/agent_relay/types.py +2 -1
  175. package/packages/sdk-py/tests/communicate/__init__.py +0 -0
  176. package/packages/sdk-py/tests/communicate/adapters/__init__.py +0 -0
  177. package/packages/sdk-py/tests/communicate/adapters/e2e_test_agno.py +154 -0
  178. package/packages/sdk-py/tests/communicate/adapters/e2e_test_claude_sdk.py +428 -0
  179. package/packages/sdk-py/tests/communicate/adapters/e2e_test_crewai.py +234 -0
  180. package/packages/sdk-py/tests/communicate/adapters/e2e_test_google_adk.py +182 -0
  181. package/packages/sdk-py/tests/communicate/adapters/e2e_test_langgraph.py +262 -0
  182. package/packages/sdk-py/tests/communicate/adapters/e2e_test_openai_agents.py +88 -0
  183. package/packages/sdk-py/tests/communicate/adapters/e2e_test_pi.py +156 -0
  184. package/packages/sdk-py/tests/communicate/adapters/e2e_test_swarms.py +239 -0
  185. package/packages/sdk-py/tests/communicate/adapters/test_agno.py +140 -0
  186. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +147 -0
  187. package/packages/sdk-py/tests/communicate/adapters/test_crewai.py +136 -0
  188. package/packages/sdk-py/tests/communicate/adapters/test_google_adk.py +125 -0
  189. package/packages/sdk-py/tests/communicate/adapters/test_openai_agents.py +99 -0
  190. package/packages/sdk-py/tests/communicate/adapters/test_pi.py +270 -0
  191. package/packages/sdk-py/tests/communicate/adapters/test_swarms.py +113 -0
  192. package/packages/sdk-py/tests/communicate/conftest.py +555 -0
  193. package/packages/sdk-py/tests/communicate/integration/__init__.py +1 -0
  194. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +331 -0
  195. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +151 -0
  196. package/packages/sdk-py/tests/communicate/test_a2a_bridge.py +363 -0
  197. package/packages/sdk-py/tests/communicate/test_a2a_server.py +346 -0
  198. package/packages/sdk-py/tests/communicate/test_a2a_transport.py +561 -0
  199. package/packages/sdk-py/tests/communicate/test_a2a_types.py +342 -0
  200. package/packages/sdk-py/tests/communicate/test_auto_detect.py +67 -0
  201. package/packages/sdk-py/tests/communicate/test_core.py +331 -0
  202. package/packages/sdk-py/tests/communicate/test_transport.py +373 -0
  203. package/packages/sdk-py/tests/communicate/test_types.py +285 -0
  204. package/packages/sdk-py/tests/test_builder_deterministic.py +118 -0
  205. package/packages/telemetry/package.json +1 -1
  206. package/packages/trajectory/package.json +2 -2
  207. package/packages/user-directory/package.json +2 -2
  208. package/packages/utils/package.json +2 -2
  209. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts +0 -14
  210. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts.map +0 -1
  211. package/packages/sdk/dist/__tests__/completion-pipeline.test.js +0 -1476
  212. package/packages/sdk/dist/__tests__/completion-pipeline.test.js.map +0 -1
  213. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts +0 -2
  214. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts.map +0 -1
  215. package/packages/sdk/dist/__tests__/contract-fixtures.test.js +0 -152
  216. package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +0 -1
  217. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts +0 -16
  218. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts.map +0 -1
  219. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js +0 -640
  220. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js.map +0 -1
  221. package/packages/sdk/dist/__tests__/facade.test.d.ts +0 -2
  222. package/packages/sdk/dist/__tests__/facade.test.d.ts.map +0 -1
  223. package/packages/sdk/dist/__tests__/facade.test.js +0 -305
  224. package/packages/sdk/dist/__tests__/facade.test.js.map +0 -1
  225. package/packages/sdk/dist/__tests__/integration.test.d.ts +0 -2
  226. package/packages/sdk/dist/__tests__/integration.test.d.ts.map +0 -1
  227. package/packages/sdk/dist/__tests__/integration.test.js +0 -205
  228. package/packages/sdk/dist/__tests__/integration.test.js.map +0 -1
  229. package/packages/sdk/dist/__tests__/pty.test.d.ts +0 -2
  230. package/packages/sdk/dist/__tests__/pty.test.d.ts.map +0 -1
  231. package/packages/sdk/dist/__tests__/pty.test.js +0 -20
  232. package/packages/sdk/dist/__tests__/pty.test.js.map +0 -1
  233. package/packages/sdk/dist/__tests__/quickstart.test.d.ts +0 -2
  234. package/packages/sdk/dist/__tests__/quickstart.test.d.ts.map +0 -1
  235. package/packages/sdk/dist/__tests__/quickstart.test.js +0 -176
  236. package/packages/sdk/dist/__tests__/quickstart.test.js.map +0 -1
  237. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts +0 -2
  238. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts.map +0 -1
  239. package/packages/sdk/dist/__tests__/spawn-from-env.test.js +0 -222
  240. package/packages/sdk/dist/__tests__/spawn-from-env.test.js.map +0 -1
  241. package/packages/sdk/dist/__tests__/unit.test.d.ts +0 -2
  242. package/packages/sdk/dist/__tests__/unit.test.d.ts.map +0 -1
  243. package/packages/sdk/dist/__tests__/unit.test.js +0 -357
  244. package/packages/sdk/dist/__tests__/unit.test.js.map +0 -1
  245. package/packages/sdk-py/agent_relay/__init__.py +0 -21
  246. package/packages/sdk-py/agent_relay/models.py +0 -398
@@ -0,0 +1,555 @@
1
+ """Shared fixtures for communicate transport tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections import defaultdict, deque
8
+ from contextlib import suppress
9
+ from itertools import count
10
+ from typing import Any
11
+
12
+ import pytest_asyncio
13
+ from aiohttp import WSMsgType, web
14
+
15
+ from agent_relay.communicate.types import RelayConfig
16
+
17
+
18
+ class MockRelayServer:
19
+ """Minimal in-process Relaycast mock that mirrors the real API surface.
20
+
21
+ Endpoints:
22
+ POST /v1/agents — register (workspace key auth)
23
+ POST /v1/agents/disconnect — unregister (agent token auth)
24
+ POST /v1/agents/heartbeat — presence heartbeat (agent token)
25
+ GET /v1/agents — list agents (workspace key auth)
26
+ POST /v1/dm — send DM (agent token auth)
27
+ POST /v1/channels/{name}/messages — channel message (agent token auth)
28
+ POST /v1/messages/{id}/replies — reply (agent token auth)
29
+ GET /v1/inbox — inbox summary (agent token auth)
30
+ GET /v1/dm/{conv_id}/messages — DM conversation messages (agent token)
31
+ GET /v1/ws — websocket (agent token via query param)
32
+ """
33
+
34
+ def __init__(self, *, api_key: str = "test-key", workspace: str = "test-workspace") -> None:
35
+ self.api_key = api_key
36
+ self.workspace = workspace
37
+ self.url = ""
38
+
39
+ self.messages: list[dict[str, Any]] = []
40
+ self.requests: dict[str, list[dict[str, Any]]] = defaultdict(list)
41
+ self.inboxes: dict[str, list[dict[str, Any]]] = defaultdict(list)
42
+ self.registered_agents: dict[str, dict[str, str]] = {}
43
+ self.extra_agents: set[str] = set()
44
+ self.received_ws_messages: list[dict[str, Any]] = []
45
+ self.ws_connection_counts: dict[str, int] = defaultdict(int)
46
+
47
+ # Map agent tokens → agent IDs for auth
48
+ self._token_to_agent_id: dict[str, str] = {}
49
+ # DM conversations: conv_id → list of messages
50
+ self._dm_conversations: dict[str, list[dict[str, Any]]] = defaultdict(list)
51
+
52
+ self._active_websockets: dict[str, web.WebSocketResponse] = {}
53
+ self._queued_errors: dict[str, deque[tuple[int, dict[str, Any]]]] = defaultdict(deque)
54
+ self._agent_ids = count(1)
55
+ self._message_ids = count(1)
56
+
57
+ self._app = web.Application()
58
+ self._app.router.add_post("/v1/agents", self._handle_register)
59
+ self._app.router.add_get("/v1/agents", self._handle_agents)
60
+ self._app.router.add_post("/v1/agents/disconnect", self._handle_disconnect)
61
+ self._app.router.add_post("/v1/agents/heartbeat", self._handle_heartbeat)
62
+ self._app.router.add_post("/v1/dm", self._handle_dm)
63
+ self._app.router.add_get("/v1/dm/{conv_id}/messages", self._handle_dm_messages)
64
+ self._app.router.add_post("/v1/channels/{channel}/messages", self._handle_channel)
65
+ self._app.router.add_post("/v1/messages/{message_id}/replies", self._handle_reply)
66
+ self._app.router.add_get("/v1/inbox", self._handle_inbox)
67
+ self._app.router.add_get("/v1/ws", self._handle_ws)
68
+
69
+ self._runner: web.AppRunner | None = None
70
+ self._site: web.TCPSite | None = None
71
+
72
+ def make_config(self, **overrides: Any) -> RelayConfig:
73
+ if not self.url:
74
+ raise RuntimeError("MockRelayServer.start() must run before make_config().")
75
+
76
+ payload: dict[str, Any] = {
77
+ "workspace": self.workspace,
78
+ "api_key": self.api_key,
79
+ "base_url": self.url,
80
+ }
81
+ payload.update(overrides)
82
+ return RelayConfig(**payload)
83
+
84
+ def queue_http_error(
85
+ self,
86
+ operation: str,
87
+ *,
88
+ status: int,
89
+ message: str,
90
+ repeat: int = 1,
91
+ ) -> None:
92
+ body = {"ok": False, "error": {"code": "error", "message": message}}
93
+ for _ in range(repeat):
94
+ self._queued_errors[operation].append((status, body))
95
+
96
+ def request_count(self, operation: str) -> int:
97
+ return len(self.requests[operation])
98
+
99
+ def add_agent(self, name: str) -> None:
100
+ self.extra_agents.add(name)
101
+
102
+ def find_agent_ids(self, name: str) -> list[str]:
103
+ return [
104
+ agent_id
105
+ for agent_id, registration in self.registered_agents.items()
106
+ if registration["name"] == name
107
+ ]
108
+
109
+ def find_agent_id(self, name: str) -> str | None:
110
+ agent_ids = self.find_agent_ids(name)
111
+ return agent_ids[0] if agent_ids else None
112
+
113
+ def queue_inbox_message(
114
+ self,
115
+ agent_id: str,
116
+ *,
117
+ sender: str,
118
+ text: str,
119
+ channel: str | None = None,
120
+ thread_id: str | None = None,
121
+ message_id: str | None = None,
122
+ timestamp: float | None = None,
123
+ ) -> dict[str, Any]:
124
+ msg_id = message_id or f"message-{next(self._message_ids)}"
125
+ payload = {
126
+ "sender": sender,
127
+ "text": text,
128
+ "channel": channel,
129
+ "thread_id": thread_id,
130
+ "message_id": msg_id,
131
+ "timestamp": timestamp,
132
+ }
133
+ # Store as a DM conversation entry for inbox retrieval
134
+ conv_id = f"dm_{agent_id}_{sender}"
135
+ dm_msg = {
136
+ "id": msg_id,
137
+ "agent_name": sender,
138
+ "text": text,
139
+ "created_at": str(timestamp) if timestamp else None,
140
+ }
141
+ self._dm_conversations[conv_id].append(dm_msg)
142
+ self.inboxes[agent_id].append({
143
+ "conversation_id": conv_id,
144
+ "from": sender,
145
+ "unread_count": 1,
146
+ "last_message": dm_msg,
147
+ })
148
+ return payload
149
+
150
+ async def push_ws_message(
151
+ self,
152
+ agent_id: str,
153
+ *,
154
+ sender: str,
155
+ text: str,
156
+ channel: str | None = None,
157
+ thread_id: str | None = None,
158
+ message_id: str | None = None,
159
+ timestamp: float | None = None,
160
+ ) -> dict[str, Any]:
161
+ ws = self._active_websockets.get(agent_id)
162
+ if ws is None or ws.closed:
163
+ raise AssertionError(f"No active websocket for agent {agent_id!r}")
164
+
165
+ payload: dict[str, Any] = {
166
+ "type": "dm.received" if channel is None else "message.created",
167
+ "agent_name": sender,
168
+ "text": text,
169
+ "id": message_id or f"message-{next(self._message_ids)}",
170
+ }
171
+ if channel is not None:
172
+ payload["channel_name"] = channel
173
+ if thread_id is not None:
174
+ payload["thread_id"] = thread_id
175
+ if timestamp is not None:
176
+ payload["created_at"] = str(timestamp)
177
+
178
+ await ws.send_json(payload)
179
+ return payload
180
+
181
+ async def close_ws(self, agent_id: str) -> None:
182
+ ws = self._active_websockets.get(agent_id)
183
+ if ws is not None and not ws.closed:
184
+ await ws.close()
185
+
186
+ async def wait_for_ws_connections(
187
+ self,
188
+ agent_id: str,
189
+ *,
190
+ count: int = 1,
191
+ timeout: float = 1.0,
192
+ ) -> None:
193
+ deadline = asyncio.get_running_loop().time() + timeout
194
+ while asyncio.get_running_loop().time() < deadline:
195
+ if self.ws_connection_counts[agent_id] >= count:
196
+ return
197
+ await asyncio.sleep(0.01)
198
+
199
+ raise AssertionError(
200
+ f"Timed out waiting for {count} websocket connection(s) for {agent_id!r}."
201
+ )
202
+
203
+ def websocket_connected(self, agent_id: str) -> bool:
204
+ ws = self._active_websockets.get(agent_id)
205
+ return ws is not None and not ws.closed
206
+
207
+ async def start(self) -> None:
208
+ self._runner = web.AppRunner(self._app)
209
+ await self._runner.setup()
210
+ self._site = web.TCPSite(self._runner, "127.0.0.1", 0)
211
+ await self._site.start()
212
+
213
+ server = getattr(self._site, "_server", None)
214
+ if server is None or not server.sockets:
215
+ raise RuntimeError("Failed to start mock Relaycast server.")
216
+
217
+ port = server.sockets[0].getsockname()[1]
218
+ self.url = f"http://127.0.0.1:{port}"
219
+
220
+ async def stop(self) -> None:
221
+ for agent_id, ws in list(self._active_websockets.items()):
222
+ if not ws.closed:
223
+ ws.force_close()
224
+ with suppress(Exception, asyncio.TimeoutError):
225
+ await asyncio.wait_for(ws.close(drain=False), timeout=0.1)
226
+ self._active_websockets.pop(agent_id, None)
227
+
228
+ if self._runner is not None:
229
+ await self._runner.cleanup()
230
+
231
+ # --- Handlers ---
232
+
233
+ async def _handle_register(self, request: web.Request) -> web.StreamResponse:
234
+ payload = await request.json()
235
+ self._record_request("register_agent", request, payload)
236
+
237
+ if error := self._pop_error("register_agent"):
238
+ return error
239
+ if error := self._require_workspace_auth(request):
240
+ return error
241
+
242
+ agent_id = f"agent-{next(self._agent_ids)}"
243
+ token = f"token-{agent_id}"
244
+ self.registered_agents[agent_id] = {"name": payload["name"], "token": token}
245
+ self._token_to_agent_id[token] = agent_id
246
+ return web.json_response({
247
+ "ok": True,
248
+ "data": {"id": agent_id, "name": payload["name"], "token": token, "status": "online"},
249
+ })
250
+
251
+ async def _handle_disconnect(self, request: web.Request) -> web.StreamResponse:
252
+ agent_id = self._resolve_agent_from_token(request)
253
+ self._record_request("unregister_agent", request, {"agent_id": agent_id})
254
+
255
+ if error := self._pop_error("unregister_agent"):
256
+ return error
257
+ if agent_id is None:
258
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
259
+
260
+ self.registered_agents.pop(agent_id, None)
261
+ self.inboxes.pop(agent_id, None)
262
+ ws = self._active_websockets.pop(agent_id, None)
263
+ if ws is not None and not ws.closed:
264
+ await ws.close()
265
+ # Remove token mapping
266
+ token = self._extract_token(request)
267
+ if token:
268
+ self._token_to_agent_id.pop(token, None)
269
+ return web.json_response({"ok": True})
270
+
271
+ async def _handle_heartbeat(self, request: web.Request) -> web.StreamResponse:
272
+ return web.json_response({"ok": True})
273
+
274
+ async def _handle_dm(self, request: web.Request) -> web.StreamResponse:
275
+ payload = await request.json()
276
+ agent_id = self._resolve_agent_from_token(request)
277
+ sender_name = self.registered_agents.get(agent_id, {}).get("name", "unknown") if agent_id else "unknown"
278
+ self._record_request("send_dm", request, payload)
279
+
280
+ if error := self._pop_error("send_dm"):
281
+ return error
282
+ if agent_id is None:
283
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
284
+
285
+ message_id = f"message-{next(self._message_ids)}"
286
+ conv_id = f"dm_{message_id}"
287
+ self.messages.append({"kind": "dm", "message_id": message_id, **payload, "from": sender_name})
288
+
289
+ # Deliver to recipient
290
+ recipient_ids = self.find_agent_ids(payload["to"])
291
+ dm_msg = {
292
+ "id": message_id,
293
+ "agent_name": sender_name,
294
+ "text": payload["text"],
295
+ "created_at": None,
296
+ }
297
+ self._dm_conversations[conv_id].append(dm_msg)
298
+ for rid in recipient_ids:
299
+ self.inboxes[rid].append({
300
+ "conversation_id": conv_id,
301
+ "from": sender_name,
302
+ "unread_count": 1,
303
+ "last_message": dm_msg,
304
+ })
305
+
306
+ await self._deliver_to_agents(
307
+ recipient_ids,
308
+ sender=sender_name,
309
+ text=payload["text"],
310
+ message_id=message_id,
311
+ )
312
+
313
+ return web.json_response({
314
+ "ok": True,
315
+ "data": {"id": message_id, "conversation_id": conv_id, "text": payload["text"]},
316
+ }, status=201)
317
+
318
+ async def _handle_dm_messages(self, request: web.Request) -> web.StreamResponse:
319
+ conv_id = request.match_info["conv_id"]
320
+ messages = list(self._dm_conversations.get(conv_id, []))
321
+ return web.json_response({"ok": True, "data": messages})
322
+
323
+ async def _handle_channel(self, request: web.Request) -> web.StreamResponse:
324
+ channel = request.match_info["channel"]
325
+ payload = await request.json()
326
+ agent_id = self._resolve_agent_from_token(request)
327
+ sender_name = self.registered_agents.get(agent_id, {}).get("name", "unknown") if agent_id else "unknown"
328
+ self._record_request("post_message", request, {**payload, "channel": channel})
329
+
330
+ if error := self._pop_error("post_message"):
331
+ return error
332
+ if agent_id is None:
333
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
334
+
335
+ message_id = f"message-{next(self._message_ids)}"
336
+ self.messages.append({"kind": "channel", "message_id": message_id, "channel": channel, **payload, "from": sender_name})
337
+ await self._deliver_to_agents(
338
+ [
339
+ aid
340
+ for aid, registration in self.registered_agents.items()
341
+ if aid != agent_id
342
+ ],
343
+ sender=sender_name,
344
+ text=payload["text"],
345
+ channel=channel,
346
+ message_id=message_id,
347
+ )
348
+ return web.json_response({
349
+ "ok": True,
350
+ "data": {"id": message_id, "channel_name": channel, "text": payload["text"]},
351
+ }, status=201)
352
+
353
+ async def _handle_reply(self, request: web.Request) -> web.StreamResponse:
354
+ parent_id = request.match_info["message_id"]
355
+ payload = await request.json()
356
+ agent_id = self._resolve_agent_from_token(request)
357
+ sender_name = self.registered_agents.get(agent_id, {}).get("name", "unknown") if agent_id else "unknown"
358
+ self._record_request("reply", request, {**payload, "message_id": parent_id})
359
+
360
+ if error := self._pop_error("reply"):
361
+ return error
362
+ if agent_id is None:
363
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
364
+
365
+ reply_id = f"message-{next(self._message_ids)}"
366
+ self.messages.append({
367
+ "kind": "reply",
368
+ "reply_id": reply_id,
369
+ "thread_id": parent_id,
370
+ "from": sender_name,
371
+ **payload,
372
+ })
373
+ return web.json_response({
374
+ "ok": True,
375
+ "data": {"id": reply_id, "text": payload["text"]},
376
+ }, status=201)
377
+
378
+ async def _handle_inbox(self, request: web.Request) -> web.StreamResponse:
379
+ agent_id = self._resolve_agent_from_token(request)
380
+ self._record_request("check_inbox", request, {"agent_id": agent_id})
381
+
382
+ if error := self._pop_error("check_inbox"):
383
+ return error
384
+ if agent_id is None:
385
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
386
+
387
+ unread_dms = list(self.inboxes.get(agent_id, []))
388
+ self.inboxes[agent_id] = []
389
+ return web.json_response({
390
+ "ok": True,
391
+ "data": {
392
+ "unread_channels": [],
393
+ "mentions": [],
394
+ "unread_dms": unread_dms,
395
+ "recent_reactions": [],
396
+ },
397
+ })
398
+
399
+ async def _handle_agents(self, request: web.Request) -> web.StreamResponse:
400
+ self._record_request("list_agents", request, None)
401
+
402
+ if error := self._pop_error("list_agents"):
403
+ return error
404
+ if error := self._require_workspace_auth(request):
405
+ return error
406
+
407
+ agent_list = [
408
+ {"name": agent["name"], "id": agent_id, "status": "online"}
409
+ for agent_id, agent in self.registered_agents.items()
410
+ ]
411
+ for name in self.extra_agents:
412
+ agent_list.append({"name": name, "id": f"extra-{name}", "status": "online"})
413
+
414
+ return web.json_response({"ok": True, "data": agent_list})
415
+
416
+ async def _handle_ws(self, request: web.Request) -> web.StreamResponse:
417
+ token = request.query.get("token")
418
+ agent_id = self._token_to_agent_id.get(token) if token else None
419
+ if agent_id is None or agent_id not in self.registered_agents:
420
+ raise web.HTTPUnauthorized(text="Invalid websocket token")
421
+
422
+ ws = web.WebSocketResponse()
423
+ await ws.prepare(request)
424
+
425
+ self._active_websockets[agent_id] = ws
426
+ self.ws_connection_counts[agent_id] += 1
427
+
428
+ try:
429
+ async for message in ws:
430
+ if message.type is WSMsgType.TEXT:
431
+ self.received_ws_messages.append(json.loads(message.data))
432
+ elif message.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}:
433
+ break
434
+ finally:
435
+ if self._active_websockets.get(agent_id) is ws:
436
+ self._active_websockets.pop(agent_id, None)
437
+
438
+ return ws
439
+
440
+ # --- Helpers ---
441
+
442
+ def _record_request(
443
+ self,
444
+ operation: str,
445
+ request: web.Request,
446
+ payload: dict[str, Any] | None,
447
+ ) -> None:
448
+ self.requests[operation].append(
449
+ {
450
+ "headers": dict(request.headers),
451
+ "json": payload,
452
+ "path": request.path,
453
+ }
454
+ )
455
+
456
+ def _pop_error(self, operation: str) -> web.StreamResponse | None:
457
+ if not self._queued_errors[operation]:
458
+ return None
459
+
460
+ status, body = self._queued_errors[operation].popleft()
461
+ return web.json_response(body, status=status)
462
+
463
+ def _extract_token(self, request: web.Request) -> str | None:
464
+ auth = request.headers.get("Authorization", "")
465
+ if auth.startswith("Bearer "):
466
+ return auth[7:]
467
+ return None
468
+
469
+ def _require_workspace_auth(self, request: web.Request) -> web.StreamResponse | None:
470
+ token = self._extract_token(request)
471
+ if token == self.api_key:
472
+ return None
473
+ return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
474
+
475
+ def _resolve_agent_from_token(self, request: web.Request) -> str | None:
476
+ token = self._extract_token(request)
477
+ if token is None:
478
+ return None
479
+ return self._token_to_agent_id.get(token)
480
+
481
+ async def _deliver_to_agents(
482
+ self,
483
+ agent_ids: list[str],
484
+ *,
485
+ sender: str,
486
+ text: str,
487
+ channel: str | None = None,
488
+ thread_id: str | None = None,
489
+ message_id: str | None = None,
490
+ timestamp: float | None = None,
491
+ ) -> None:
492
+ if not agent_ids:
493
+ return
494
+
495
+ for agent_id in agent_ids:
496
+ await self._deliver_message(agent_id, sender=sender, text=text,
497
+ channel=channel, thread_id=thread_id,
498
+ message_id=message_id, timestamp=timestamp)
499
+
500
+ async def _deliver_message(
501
+ self,
502
+ agent_id: str,
503
+ *,
504
+ sender: str,
505
+ text: str,
506
+ channel: str | None = None,
507
+ thread_id: str | None = None,
508
+ message_id: str | None = None,
509
+ timestamp: float | None = None,
510
+ ) -> None:
511
+ ws = self._active_websockets.get(agent_id)
512
+ if ws is not None and not ws.closed:
513
+ payload: dict[str, Any] = {
514
+ "type": "dm.received" if channel is None else "message.created",
515
+ "agent_name": sender,
516
+ "text": text,
517
+ "id": message_id or f"message-{next(self._message_ids)}",
518
+ }
519
+ if channel is not None:
520
+ payload["channel_name"] = channel
521
+ if thread_id is not None:
522
+ payload["thread_id"] = thread_id
523
+ if timestamp is not None:
524
+ payload["created_at"] = str(timestamp)
525
+ try:
526
+ await ws.send_json(payload)
527
+ return
528
+ except Exception:
529
+ self._active_websockets.pop(agent_id, None)
530
+
531
+ # Fall back to inbox buffering
532
+ conv_id = f"dm_{sender}_{agent_id}"
533
+ dm_msg = {
534
+ "id": message_id or f"message-{next(self._message_ids)}",
535
+ "agent_name": sender,
536
+ "text": text,
537
+ "created_at": str(timestamp) if timestamp else None,
538
+ }
539
+ self._dm_conversations[conv_id].append(dm_msg)
540
+ self.inboxes[agent_id].append({
541
+ "conversation_id": conv_id,
542
+ "from": sender,
543
+ "unread_count": 1,
544
+ "last_message": dm_msg,
545
+ })
546
+
547
+
548
+ @pytest_asyncio.fixture
549
+ async def relay_server() -> MockRelayServer:
550
+ server = MockRelayServer()
551
+ await server.start()
552
+ try:
553
+ yield server
554
+ finally:
555
+ await server.stop()