agent-relay 3.2.3 → 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 (225) hide show
  1. package/dist/index.cjs +265 -108
  2. package/package.json +11 -10
  3. package/packages/acp-bridge/package.json +2 -2
  4. package/packages/config/package.json +1 -1
  5. package/packages/hooks/package.json +4 -4
  6. package/packages/memory/package.json +2 -2
  7. package/packages/openclaw/package.json +2 -2
  8. package/packages/policy/package.json +2 -2
  9. package/packages/sdk/ADAPTER_REVIEW.md +109 -0
  10. package/packages/sdk/dist/communicate/a2a-bridge.d.ts +25 -0
  11. package/packages/sdk/dist/communicate/a2a-bridge.d.ts.map +1 -0
  12. package/packages/sdk/dist/communicate/a2a-bridge.js +89 -0
  13. package/packages/sdk/dist/communicate/a2a-bridge.js.map +1 -0
  14. package/packages/sdk/dist/communicate/a2a-server.d.ts +31 -0
  15. package/packages/sdk/dist/communicate/a2a-server.d.ts.map +1 -0
  16. package/packages/sdk/dist/communicate/a2a-server.js +220 -0
  17. package/packages/sdk/dist/communicate/a2a-server.js.map +1 -0
  18. package/packages/sdk/dist/communicate/a2a-transport.d.ts +48 -0
  19. package/packages/sdk/dist/communicate/a2a-transport.d.ts.map +1 -0
  20. package/packages/sdk/dist/communicate/a2a-transport.js +302 -0
  21. package/packages/sdk/dist/communicate/a2a-transport.js.map +1 -0
  22. package/packages/sdk/dist/communicate/a2a-types.d.ts +107 -0
  23. package/packages/sdk/dist/communicate/a2a-types.d.ts.map +1 -0
  24. package/packages/sdk/dist/communicate/a2a-types.js +209 -0
  25. package/packages/sdk/dist/communicate/a2a-types.js.map +1 -0
  26. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts +28 -0
  27. package/packages/sdk/dist/communicate/adapters/claude-sdk.d.ts.map +1 -0
  28. package/packages/sdk/dist/communicate/adapters/claude-sdk.js +47 -0
  29. package/packages/sdk/dist/communicate/adapters/claude-sdk.js.map +1 -0
  30. package/packages/sdk/dist/communicate/adapters/crewai.d.ts +42 -0
  31. package/packages/sdk/dist/communicate/adapters/crewai.d.ts.map +1 -0
  32. package/packages/sdk/dist/communicate/adapters/crewai.js +95 -0
  33. package/packages/sdk/dist/communicate/adapters/crewai.js.map +1 -0
  34. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts +53 -0
  35. package/packages/sdk/dist/communicate/adapters/google-adk.d.ts.map +1 -0
  36. package/packages/sdk/dist/communicate/adapters/google-adk.js +77 -0
  37. package/packages/sdk/dist/communicate/adapters/google-adk.js.map +1 -0
  38. package/packages/sdk/dist/communicate/adapters/index.d.ts +7 -0
  39. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -0
  40. package/packages/sdk/dist/communicate/adapters/index.js +7 -0
  41. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -0
  42. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts +40 -0
  43. package/packages/sdk/dist/communicate/adapters/langgraph.d.ts.map +1 -0
  44. package/packages/sdk/dist/communicate/adapters/langgraph.js +77 -0
  45. package/packages/sdk/dist/communicate/adapters/langgraph.js.map +1 -0
  46. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts +25 -0
  47. package/packages/sdk/dist/communicate/adapters/openai-agents.d.ts.map +1 -0
  48. package/packages/sdk/dist/communicate/adapters/openai-agents.js +70 -0
  49. package/packages/sdk/dist/communicate/adapters/openai-agents.js.map +1 -0
  50. package/packages/sdk/dist/communicate/adapters/pi.d.ts +45 -0
  51. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -0
  52. package/packages/sdk/dist/communicate/adapters/pi.js +59 -0
  53. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -0
  54. package/packages/sdk/dist/communicate/core.d.ts +58 -0
  55. package/packages/sdk/dist/communicate/core.d.ts.map +1 -0
  56. package/packages/sdk/dist/communicate/core.js +128 -0
  57. package/packages/sdk/dist/communicate/core.js.map +1 -0
  58. package/packages/sdk/dist/communicate/index.d.ts +4 -0
  59. package/packages/sdk/dist/communicate/index.d.ts.map +1 -0
  60. package/packages/sdk/dist/communicate/index.js +4 -0
  61. package/packages/sdk/dist/communicate/index.js.map +1 -0
  62. package/packages/sdk/dist/communicate/transport.d.ts +36 -0
  63. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -0
  64. package/packages/sdk/dist/communicate/transport.js +371 -0
  65. package/packages/sdk/dist/communicate/transport.js.map +1 -0
  66. package/packages/sdk/dist/communicate/types.d.ts +58 -0
  67. package/packages/sdk/dist/communicate/types.d.ts.map +1 -0
  68. package/packages/sdk/dist/communicate/types.js +66 -0
  69. package/packages/sdk/dist/communicate/types.js.map +1 -0
  70. package/packages/sdk/dist/workflows/builder.d.ts +35 -5
  71. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  72. package/packages/sdk/dist/workflows/builder.js +81 -7
  73. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  74. package/packages/sdk/dist/workflows/cli.js +14 -1
  75. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  76. package/packages/sdk/dist/workflows/runner.d.ts +10 -2
  77. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  78. package/packages/sdk/dist/workflows/runner.js +95 -1
  79. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  80. package/packages/sdk/dist/workflows/types.d.ts +11 -0
  81. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  82. package/packages/sdk/examples/communicate/claude_sdk_example.ts +5 -0
  83. package/packages/sdk/examples/communicate/pi_example.ts +8 -0
  84. package/packages/sdk/package.json +48 -2
  85. package/packages/sdk/src/__tests__/builder-deterministic.test.ts +132 -0
  86. package/packages/sdk/src/__tests__/communicate/a2a-bridge.test.ts +211 -0
  87. package/packages/sdk/src/__tests__/communicate/a2a-server.test.ts +359 -0
  88. package/packages/sdk/src/__tests__/communicate/a2a-transport.test.ts +537 -0
  89. package/packages/sdk/src/__tests__/communicate/a2a-types.test.ts +297 -0
  90. package/packages/sdk/src/__tests__/communicate/adapters/claude-sdk.test.ts +163 -0
  91. package/packages/sdk/src/__tests__/communicate/adapters/crewai.test.ts +219 -0
  92. package/packages/sdk/src/__tests__/communicate/adapters/e2e-crewai.test.ts +101 -0
  93. package/packages/sdk/src/__tests__/communicate/adapters/e2e-google-adk.test.ts +166 -0
  94. package/packages/sdk/src/__tests__/communicate/adapters/e2e-langgraph.test.ts +181 -0
  95. package/packages/sdk/src/__tests__/communicate/adapters/e2e-openai-agents.test.ts +137 -0
  96. package/packages/sdk/src/__tests__/communicate/adapters/e2e-pi.test.ts +140 -0
  97. package/packages/sdk/src/__tests__/communicate/adapters/google-adk.test.ts +200 -0
  98. package/packages/sdk/src/__tests__/communicate/adapters/langgraph.test.ts +162 -0
  99. package/packages/sdk/src/__tests__/communicate/adapters/openai-agents.test.ts +166 -0
  100. package/packages/sdk/src/__tests__/communicate/adapters/pi.test.ts +140 -0
  101. package/packages/sdk/src/__tests__/communicate/core.test.ts +574 -0
  102. package/packages/sdk/src/__tests__/communicate/integration/cross-framework.test.ts +353 -0
  103. package/packages/sdk/src/__tests__/communicate/transport.test.ts +613 -0
  104. package/packages/sdk/src/__tests__/start-from.test.ts +346 -0
  105. package/packages/sdk/src/communicate/a2a-bridge.ts +111 -0
  106. package/packages/sdk/src/communicate/a2a-server.ts +277 -0
  107. package/packages/sdk/src/communicate/a2a-transport.ts +395 -0
  108. package/packages/sdk/src/communicate/a2a-types.ts +338 -0
  109. package/packages/sdk/src/communicate/adapters/claude-sdk.ts +85 -0
  110. package/packages/sdk/src/communicate/adapters/crewai.ts +141 -0
  111. package/packages/sdk/src/communicate/adapters/google-adk.ts +139 -0
  112. package/packages/sdk/src/communicate/adapters/index.ts +6 -0
  113. package/packages/sdk/src/communicate/adapters/langgraph.ts +112 -0
  114. package/packages/sdk/src/communicate/adapters/openai-agents.ts +113 -0
  115. package/packages/sdk/src/communicate/adapters/pi.ts +105 -0
  116. package/packages/sdk/src/communicate/core.ts +157 -0
  117. package/packages/sdk/src/communicate/index.ts +3 -0
  118. package/packages/sdk/src/communicate/transport.ts +489 -0
  119. package/packages/sdk/src/communicate/types.ts +106 -0
  120. package/packages/sdk/src/examples/workflows/fix-dashboard-user-registration.yaml +182 -0
  121. package/packages/sdk/src/workflows/builder.ts +97 -9
  122. package/packages/sdk/src/workflows/cli.ts +16 -1
  123. package/packages/sdk/src/workflows/runner.ts +110 -1
  124. package/packages/sdk/src/workflows/types.ts +14 -0
  125. package/packages/sdk/tsconfig.build.json +1 -7
  126. package/packages/sdk/tsconfig.json +1 -7
  127. package/packages/sdk-py/README.md +67 -25
  128. package/packages/sdk-py/examples/communicate/agno_example.py +8 -0
  129. package/packages/sdk-py/examples/communicate/claude_sdk_example.py +6 -0
  130. package/packages/sdk-py/examples/communicate/crewai_example.py +7 -0
  131. package/packages/sdk-py/examples/communicate/google_adk_example.py +7 -0
  132. package/packages/sdk-py/examples/communicate/openai_agents_example.py +8 -0
  133. package/packages/sdk-py/examples/communicate/swarms_example.py +7 -0
  134. package/packages/sdk-py/pyproject.toml +12 -1
  135. package/packages/sdk-py/src/agent_relay/__init__.py +8 -0
  136. package/packages/sdk-py/src/agent_relay/builder.py +65 -26
  137. package/packages/sdk-py/src/agent_relay/communicate/__init__.py +6 -0
  138. package/packages/sdk-py/src/agent_relay/communicate/a2a_bridge.py +138 -0
  139. package/packages/sdk-py/src/agent_relay/communicate/a2a_server.py +242 -0
  140. package/packages/sdk-py/src/agent_relay/communicate/a2a_transport.py +366 -0
  141. package/packages/sdk-py/src/agent_relay/communicate/a2a_types.py +294 -0
  142. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +10 -0
  143. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +74 -0
  144. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +78 -0
  145. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +143 -0
  146. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +69 -0
  147. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +86 -0
  148. package/packages/sdk-py/src/agent_relay/communicate/adapters/pi.py +175 -0
  149. package/packages/sdk-py/src/agent_relay/communicate/adapters/swarms.py +44 -0
  150. package/packages/sdk-py/src/agent_relay/communicate/core.py +293 -0
  151. package/packages/sdk-py/src/agent_relay/communicate/transport.py +502 -0
  152. package/packages/sdk-py/src/agent_relay/communicate/types.py +89 -0
  153. package/packages/sdk-py/src/agent_relay/types.py +2 -1
  154. package/packages/sdk-py/tests/communicate/__init__.py +0 -0
  155. package/packages/sdk-py/tests/communicate/adapters/__init__.py +0 -0
  156. package/packages/sdk-py/tests/communicate/adapters/e2e_test_agno.py +154 -0
  157. package/packages/sdk-py/tests/communicate/adapters/e2e_test_claude_sdk.py +428 -0
  158. package/packages/sdk-py/tests/communicate/adapters/e2e_test_crewai.py +234 -0
  159. package/packages/sdk-py/tests/communicate/adapters/e2e_test_google_adk.py +182 -0
  160. package/packages/sdk-py/tests/communicate/adapters/e2e_test_langgraph.py +262 -0
  161. package/packages/sdk-py/tests/communicate/adapters/e2e_test_openai_agents.py +88 -0
  162. package/packages/sdk-py/tests/communicate/adapters/e2e_test_pi.py +156 -0
  163. package/packages/sdk-py/tests/communicate/adapters/e2e_test_swarms.py +239 -0
  164. package/packages/sdk-py/tests/communicate/adapters/test_agno.py +140 -0
  165. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +147 -0
  166. package/packages/sdk-py/tests/communicate/adapters/test_crewai.py +136 -0
  167. package/packages/sdk-py/tests/communicate/adapters/test_google_adk.py +125 -0
  168. package/packages/sdk-py/tests/communicate/adapters/test_openai_agents.py +99 -0
  169. package/packages/sdk-py/tests/communicate/adapters/test_pi.py +270 -0
  170. package/packages/sdk-py/tests/communicate/adapters/test_swarms.py +113 -0
  171. package/packages/sdk-py/tests/communicate/conftest.py +555 -0
  172. package/packages/sdk-py/tests/communicate/integration/__init__.py +1 -0
  173. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +331 -0
  174. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +151 -0
  175. package/packages/sdk-py/tests/communicate/test_a2a_bridge.py +363 -0
  176. package/packages/sdk-py/tests/communicate/test_a2a_server.py +346 -0
  177. package/packages/sdk-py/tests/communicate/test_a2a_transport.py +561 -0
  178. package/packages/sdk-py/tests/communicate/test_a2a_types.py +342 -0
  179. package/packages/sdk-py/tests/communicate/test_auto_detect.py +67 -0
  180. package/packages/sdk-py/tests/communicate/test_core.py +331 -0
  181. package/packages/sdk-py/tests/communicate/test_transport.py +373 -0
  182. package/packages/sdk-py/tests/communicate/test_types.py +285 -0
  183. package/packages/sdk-py/tests/test_builder_deterministic.py +118 -0
  184. package/packages/telemetry/package.json +1 -1
  185. package/packages/trajectory/package.json +2 -2
  186. package/packages/user-directory/package.json +2 -2
  187. package/packages/utils/package.json +2 -2
  188. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts +0 -14
  189. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts.map +0 -1
  190. package/packages/sdk/dist/__tests__/completion-pipeline.test.js +0 -1476
  191. package/packages/sdk/dist/__tests__/completion-pipeline.test.js.map +0 -1
  192. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts +0 -2
  193. package/packages/sdk/dist/__tests__/contract-fixtures.test.d.ts.map +0 -1
  194. package/packages/sdk/dist/__tests__/contract-fixtures.test.js +0 -152
  195. package/packages/sdk/dist/__tests__/contract-fixtures.test.js.map +0 -1
  196. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts +0 -16
  197. package/packages/sdk/dist/__tests__/e2e-owner-review.test.d.ts.map +0 -1
  198. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js +0 -640
  199. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js.map +0 -1
  200. package/packages/sdk/dist/__tests__/facade.test.d.ts +0 -2
  201. package/packages/sdk/dist/__tests__/facade.test.d.ts.map +0 -1
  202. package/packages/sdk/dist/__tests__/facade.test.js +0 -305
  203. package/packages/sdk/dist/__tests__/facade.test.js.map +0 -1
  204. package/packages/sdk/dist/__tests__/integration.test.d.ts +0 -2
  205. package/packages/sdk/dist/__tests__/integration.test.d.ts.map +0 -1
  206. package/packages/sdk/dist/__tests__/integration.test.js +0 -205
  207. package/packages/sdk/dist/__tests__/integration.test.js.map +0 -1
  208. package/packages/sdk/dist/__tests__/pty.test.d.ts +0 -2
  209. package/packages/sdk/dist/__tests__/pty.test.d.ts.map +0 -1
  210. package/packages/sdk/dist/__tests__/pty.test.js +0 -20
  211. package/packages/sdk/dist/__tests__/pty.test.js.map +0 -1
  212. package/packages/sdk/dist/__tests__/quickstart.test.d.ts +0 -2
  213. package/packages/sdk/dist/__tests__/quickstart.test.d.ts.map +0 -1
  214. package/packages/sdk/dist/__tests__/quickstart.test.js +0 -176
  215. package/packages/sdk/dist/__tests__/quickstart.test.js.map +0 -1
  216. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts +0 -2
  217. package/packages/sdk/dist/__tests__/spawn-from-env.test.d.ts.map +0 -1
  218. package/packages/sdk/dist/__tests__/spawn-from-env.test.js +0 -222
  219. package/packages/sdk/dist/__tests__/spawn-from-env.test.js.map +0 -1
  220. package/packages/sdk/dist/__tests__/unit.test.d.ts +0 -2
  221. package/packages/sdk/dist/__tests__/unit.test.d.ts.map +0 -1
  222. package/packages/sdk/dist/__tests__/unit.test.js +0 -357
  223. package/packages/sdk/dist/__tests__/unit.test.js.map +0 -1
  224. package/packages/sdk-py/agent_relay/__init__.py +0 -21
  225. package/packages/sdk-py/agent_relay/models.py +0 -398
@@ -0,0 +1,331 @@
1
+ """Wave 1.3 tests for the communicate Relay core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+ from inspect import isawaitable
8
+
9
+ import pytest
10
+
11
+ from agent_relay.communicate.types import Message, RelayConfig
12
+
13
+
14
+ def _core_module():
15
+ return importlib.import_module("agent_relay.communicate.core")
16
+
17
+
18
+ async def _wait_for(predicate, timeout: float = 1.0) -> None:
19
+ deadline = asyncio.get_running_loop().time() + timeout
20
+ while asyncio.get_running_loop().time() < deadline:
21
+ if predicate():
22
+ return
23
+ await asyncio.sleep(0.01)
24
+ raise AssertionError("Timed out waiting for async condition.")
25
+
26
+
27
+ @pytest.fixture
28
+ def core_harness(monkeypatch):
29
+ core = _core_module()
30
+ registered_atexit: list[object] = []
31
+
32
+ class FakeTransport:
33
+ instances: list["FakeTransport"] = []
34
+
35
+ def __init__(self, agent_name: str, config: RelayConfig) -> None:
36
+ self.agent_name = agent_name
37
+ self.config = config
38
+ self.connect_count = 0
39
+ self.disconnect_count = 0
40
+ self.send_dm_calls: list[tuple[str, str]] = []
41
+ self.post_message_calls: list[tuple[str, str]] = []
42
+ self.reply_calls: list[tuple[str, str]] = []
43
+ self.list_agents_calls = 0
44
+ self.list_agents_result = ["Review-Core", "Impl-Core"]
45
+ self.ws_callback = None
46
+ self.connected = False
47
+ FakeTransport.instances.append(self)
48
+
49
+ async def connect(self) -> None:
50
+ self.connect_count += 1
51
+ await asyncio.sleep(0)
52
+ self.connected = True
53
+
54
+ async def disconnect(self) -> None:
55
+ self.disconnect_count += 1
56
+ self.connected = False
57
+
58
+ async def send_dm(self, to: str, text: str) -> str:
59
+ self.send_dm_calls.append((to, text))
60
+ return "message-send"
61
+
62
+ async def post_message(self, channel: str, text: str) -> str:
63
+ self.post_message_calls.append((channel, text))
64
+ return "message-post"
65
+
66
+ async def reply(self, message_id: str, text: str) -> str:
67
+ self.reply_calls.append((message_id, text))
68
+ return "message-reply"
69
+
70
+ async def list_agents(self) -> list[str]:
71
+ self.list_agents_calls += 1
72
+ return list(self.list_agents_result)
73
+
74
+ def on_ws_message(self, callback) -> None:
75
+ self.ws_callback = callback
76
+
77
+ async def emit_message(self, message: Message) -> None:
78
+ if self.ws_callback is None:
79
+ raise AssertionError("Relay did not register a transport WS callback.")
80
+ result = self.ws_callback(message)
81
+ if isawaitable(result):
82
+ await result
83
+
84
+ monkeypatch.setattr(core, "RelayTransport", FakeTransport)
85
+ monkeypatch.setattr(core.atexit, "register", lambda callback: registered_atexit.append(callback))
86
+
87
+ return core, FakeTransport, registered_atexit
88
+
89
+
90
+ def test_init_is_lazy_and_registers_atexit_cleanup(core_harness):
91
+ core, FakeTransport, registered_atexit = core_harness
92
+
93
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=True))
94
+
95
+ assert sum(transport.connect_count for transport in FakeTransport.instances) == 0
96
+ assert len(registered_atexit) == 1
97
+ assert getattr(registered_atexit[0], "__self__", None) is relay
98
+ assert getattr(registered_atexit[0], "__name__", None) == "close_sync"
99
+
100
+
101
+ def test_init_skips_atexit_cleanup_when_disabled(core_harness):
102
+ core, _FakeTransport, registered_atexit = core_harness
103
+
104
+ core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
105
+
106
+ assert registered_atexit == []
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_send_lazy_connects_and_delegates_to_transport(core_harness):
111
+ core, FakeTransport, _registered_atexit = core_harness
112
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
113
+ transport = FakeTransport.instances[0]
114
+
115
+ result = await relay.send("Impl-Core", "hello")
116
+
117
+ assert result is None
118
+ assert transport.connect_count == 1
119
+ assert transport.send_dm_calls == [("Impl-Core", "hello")]
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_post_lazy_connects_and_delegates_to_transport(core_harness):
124
+ core, FakeTransport, _registered_atexit = core_harness
125
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
126
+ transport = FakeTransport.instances[0]
127
+
128
+ result = await relay.post("core-py", "status update")
129
+
130
+ assert result is None
131
+ assert transport.connect_count == 1
132
+ assert transport.post_message_calls == [("core-py", "status update")]
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_reply_lazy_connects_and_delegates_to_transport(core_harness):
137
+ core, FakeTransport, _registered_atexit = core_harness
138
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
139
+ transport = FakeTransport.instances[0]
140
+
141
+ result = await relay.reply("message-123", "thread response")
142
+
143
+ assert result is None
144
+ assert transport.connect_count == 1
145
+ assert transport.reply_calls == [("message-123", "thread response")]
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_inbox_drains_pending_buffer_and_does_not_poll_transport(core_harness):
150
+ core, FakeTransport, _registered_atexit = core_harness
151
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
152
+ transport = FakeTransport.instances[0]
153
+ relay._pending.extend(
154
+ [
155
+ Message(sender="Review-Core", text="one", message_id="message-1"),
156
+ Message(sender="Impl-Core", text="two", message_id="message-2"),
157
+ ]
158
+ )
159
+
160
+ first = await relay.inbox()
161
+ second = await relay.inbox()
162
+
163
+ assert first == [
164
+ Message(sender="Review-Core", text="one", message_id="message-1"),
165
+ Message(sender="Impl-Core", text="two", message_id="message-2"),
166
+ ]
167
+ assert second == []
168
+ assert relay._pending == []
169
+ assert transport.connect_count == 1
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_on_message_registers_callback_and_unsubscribe_restores_buffering(core_harness):
174
+ core, FakeTransport, _registered_atexit = core_harness
175
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
176
+ transport = FakeTransport.instances[0]
177
+
178
+ received: list[Message] = []
179
+ unsubscribe = relay.on_message(lambda message: received.append(message))
180
+
181
+ await _wait_for(lambda: transport.connect_count == 1)
182
+
183
+ callback_message = Message(sender="Review-Core", text="callback", message_id="message-cb")
184
+ buffered_message = Message(sender="Impl-Core", text="buffered", message_id="message-buffer")
185
+
186
+ await transport.emit_message(callback_message)
187
+ unsubscribe()
188
+ await transport.emit_message(buffered_message)
189
+ inbox_messages = await relay.inbox()
190
+
191
+ assert received == [callback_message]
192
+ # "both" case: callback messages are also buffered per spec Section 5.3
193
+ assert inbox_messages == [callback_message, buffered_message]
194
+
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_pending_buffer_caps_at_ten_thousand_and_drops_oldest_with_warning(core_harness):
198
+ core, FakeTransport, _registered_atexit = core_harness
199
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
200
+ transport = FakeTransport.instances[0]
201
+
202
+ await relay.agents()
203
+
204
+ with pytest.warns(UserWarning, match="10,000|10000|buffer"):
205
+ for index in range(10_001):
206
+ await transport.emit_message(
207
+ Message(
208
+ sender="Review-Core",
209
+ text=f"message-{index}",
210
+ message_id=f"message-{index}",
211
+ )
212
+ )
213
+
214
+ messages = await relay.inbox()
215
+
216
+ assert len(messages) == 10_000
217
+ assert messages[0].message_id == "message-1"
218
+ assert messages[-1].message_id == "message-10000"
219
+
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_agents_returns_transport_agent_list(core_harness):
223
+ core, FakeTransport, _registered_atexit = core_harness
224
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
225
+ transport = FakeTransport.instances[0]
226
+ transport.list_agents_result = ["Review-Core", "Impl-Core", "CoreTester"]
227
+
228
+ agents = await relay.agents()
229
+
230
+ assert agents == ["Review-Core", "Impl-Core", "CoreTester"]
231
+ assert transport.connect_count == 1
232
+ assert transport.list_agents_calls == 1
233
+
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_close_disconnects_transport(core_harness):
237
+ core, FakeTransport, _registered_atexit = core_harness
238
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
239
+ transport = FakeTransport.instances[0]
240
+
241
+ await relay.send("Impl-Core", "hello")
242
+ await relay.close()
243
+
244
+ assert transport.disconnect_count == 1
245
+
246
+
247
+ def test_sync_wrappers_delegate_to_async_methods(core_harness):
248
+ core, FakeTransport, _registered_atexit = core_harness
249
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
250
+ transport = FakeTransport.instances[0]
251
+ relay._pending.append(Message(sender="Review-Core", text="sync inbox", message_id="message-sync"))
252
+ transport.list_agents_result = ["Review-Core", "Impl-Core"]
253
+
254
+ relay.send_sync("Impl-Core", "sync hello")
255
+ relay.post_sync("core-py", "sync update")
256
+ inbox_messages = relay.inbox_sync()
257
+ agents = relay.agents_sync()
258
+ relay.close_sync()
259
+
260
+ assert transport.send_dm_calls == [("Impl-Core", "sync hello")]
261
+ assert transport.post_message_calls == [("core-py", "sync update")]
262
+ assert inbox_messages == [Message(sender="Review-Core", text="sync inbox", message_id="message-sync")]
263
+ assert agents == ["Review-Core", "Impl-Core"]
264
+ assert transport.disconnect_count == 1
265
+
266
+
267
+ @pytest.mark.asyncio
268
+ async def test_async_context_manager_closes_transport_on_exit(core_harness):
269
+ core, FakeTransport, _registered_atexit = core_harness
270
+
271
+ async with core.Relay("CoreTester", RelayConfig(auto_cleanup=False)) as relay:
272
+ await relay.send("Impl-Core", "inside context")
273
+ transport = FakeTransport.instances[0]
274
+ assert transport.connect_count == 1
275
+
276
+ assert transport.disconnect_count == 1
277
+
278
+
279
+ @pytest.mark.asyncio
280
+ async def test_concurrent_inbox_calls_do_not_lose_messages(core_harness):
281
+ core, FakeTransport, _registered_atexit = core_harness
282
+ relay = core.Relay("CoreTester", RelayConfig(auto_cleanup=False))
283
+ relay._pending.extend(
284
+ [
285
+ Message(sender="one", text="first", message_id="message-1"),
286
+ Message(sender="two", text="second", message_id="message-2"),
287
+ Message(sender="three", text="third", message_id="message-3"),
288
+ ]
289
+ )
290
+
291
+ results = await asyncio.gather(relay.inbox(), relay.inbox())
292
+
293
+ combined = [message.message_id for batch in results for message in batch]
294
+ assert sorted(combined) == ["message-1", "message-2", "message-3"]
295
+ assert relay._pending == []
296
+
297
+
298
+ @pytest.mark.asyncio
299
+ async def test_multiple_relay_instances_are_independent(core_harness):
300
+ core, FakeTransport, _registered_atexit = core_harness
301
+ first = core.Relay("FirstRelay", RelayConfig(auto_cleanup=False))
302
+ second = core.Relay("SecondRelay", RelayConfig(auto_cleanup=False))
303
+ first_transport, second_transport = FakeTransport.instances
304
+
305
+ await first.agents()
306
+ await second.agents()
307
+
308
+ await first_transport.emit_message(
309
+ Message(sender="Review-Core", text="first only", message_id="message-first")
310
+ )
311
+ await second_transport.emit_message(
312
+ Message(sender="Impl-Core", text="second only", message_id="message-second")
313
+ )
314
+
315
+ assert await first.inbox() == [
316
+ Message(sender="Review-Core", text="first only", message_id="message-first")
317
+ ]
318
+ assert await second.inbox() == [
319
+ Message(sender="Impl-Core", text="second only", message_id="message-second")
320
+ ]
321
+ assert first_transport is not second_transport
322
+
323
+
324
+ def test_communicate_package_reexports_public_core_symbols():
325
+ core = _core_module()
326
+ communicate = importlib.reload(importlib.import_module("agent_relay.communicate"))
327
+
328
+ assert communicate.Relay is core.Relay
329
+ assert communicate.Message is Message
330
+ assert communicate.RelayConfig is RelayConfig
331
+ assert hasattr(communicate, "on_relay")
@@ -0,0 +1,373 @@
1
+ """Tests for the RelayTransport HTTP/WS client against real Relaycast API surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import importlib
7
+
8
+ import pytest
9
+
10
+ from agent_relay.communicate.types import (
11
+ Message,
12
+ RelayAuthError,
13
+ RelayConfigError,
14
+ RelayConnectionError,
15
+ )
16
+
17
+ _ORIGINAL_ASYNCIO_SLEEP = asyncio.sleep
18
+
19
+
20
+ def _transport_module():
21
+ return importlib.import_module("agent_relay.communicate.transport")
22
+
23
+
24
+ def _transport_class():
25
+ return _transport_module().RelayTransport
26
+
27
+
28
+ async def _wait_for(predicate, timeout: float = 1.0) -> None:
29
+ deadline = asyncio.get_running_loop().time() + timeout
30
+ while asyncio.get_running_loop().time() < deadline:
31
+ if predicate():
32
+ return
33
+ await _ORIGINAL_ASYNCIO_SLEEP(0.01)
34
+
35
+ raise AssertionError("Timed out waiting for async condition.")
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_register_agent_and_unregister_agent_manage_identity(relay_server):
40
+ RelayTransport = _transport_class()
41
+ transport = RelayTransport("TransportTester", relay_server.make_config())
42
+
43
+ await transport.register_agent()
44
+
45
+ assert transport.agent_id in relay_server.registered_agents
46
+ assert transport.token == relay_server.registered_agents[transport.agent_id]["token"]
47
+ register_payload = relay_server.requests["register_agent"][-1]["json"]
48
+ assert register_payload["name"] == "TransportTester"
49
+
50
+ agent_id = transport.agent_id
51
+ await transport.unregister_agent()
52
+
53
+ assert relay_server.request_count("unregister_agent") == 1
54
+ assert agent_id not in relay_server.registered_agents
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_connect_and_disconnect_manage_registration_and_websocket(relay_server):
59
+ RelayTransport = _transport_class()
60
+ transport = RelayTransport("TransportTester", relay_server.make_config())
61
+
62
+ await transport.connect()
63
+
64
+ assert relay_server.request_count("register_agent") == 1
65
+ assert transport.agent_id is not None
66
+ await relay_server.wait_for_ws_connections(transport.agent_id, count=1)
67
+ assert relay_server.websocket_connected(transport.agent_id)
68
+
69
+ agent_id = transport.agent_id
70
+ await transport.disconnect()
71
+
72
+ assert relay_server.request_count("unregister_agent") == 1
73
+ assert agent_id not in relay_server.registered_agents
74
+ assert not relay_server.websocket_connected(agent_id)
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_send_dm_posts_to_correct_endpoint(relay_server):
79
+ RelayTransport = _transport_class()
80
+ transport = RelayTransport("TransportTester", relay_server.make_config())
81
+ await transport.connect()
82
+
83
+ try:
84
+ message_id = await transport.send_dm("Review-Core", "hello")
85
+ finally:
86
+ await transport.disconnect()
87
+
88
+ assert message_id.startswith("message-")
89
+ dm_req = relay_server.requests["send_dm"][-1]
90
+ assert dm_req["json"]["to"] == "Review-Core"
91
+ assert dm_req["json"]["text"] == "hello"
92
+ assert dm_req["path"] == "/v1/dm"
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_post_message_posts_to_channel_endpoint(relay_server):
97
+ RelayTransport = _transport_class()
98
+ transport = RelayTransport("TransportTester", relay_server.make_config())
99
+ await transport.connect()
100
+
101
+ try:
102
+ message_id = await transport.post_message("core-py", "status update")
103
+ finally:
104
+ await transport.disconnect()
105
+
106
+ assert message_id.startswith("message-")
107
+ ch_req = relay_server.requests["post_message"][-1]
108
+ assert ch_req["json"]["text"] == "status update"
109
+ assert "/v1/channels/core-py/messages" in ch_req["path"]
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_reply_posts_to_replies_endpoint(relay_server):
114
+ RelayTransport = _transport_class()
115
+ transport = RelayTransport("TransportTester", relay_server.make_config())
116
+ await transport.connect()
117
+
118
+ try:
119
+ message_id = await transport.reply("message-123", "thread reply")
120
+ finally:
121
+ await transport.disconnect()
122
+
123
+ assert message_id.startswith("message-")
124
+ reply_req = relay_server.requests["reply"][-1]
125
+ assert reply_req["json"]["text"] == "thread reply"
126
+ assert "/v1/messages/message-123/replies" in reply_req["path"]
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_check_inbox_returns_message_objects_and_drains_server_inbox(relay_server):
131
+ RelayTransport = _transport_class()
132
+ transport = RelayTransport("TransportTester", relay_server.make_config())
133
+ await transport.connect()
134
+
135
+ try:
136
+ queued = relay_server.queue_inbox_message(
137
+ transport.agent_id,
138
+ sender="Impl-Core",
139
+ text="transport ready",
140
+ channel="core-py",
141
+ thread_id="thread-1",
142
+ message_id="message-inbox-1",
143
+ timestamp=1710300000.5,
144
+ )
145
+
146
+ messages = await transport.check_inbox()
147
+ empty = await transport.check_inbox()
148
+ finally:
149
+ await transport.disconnect()
150
+
151
+ assert len(messages) == 1
152
+ assert messages[0].sender == "Impl-Core"
153
+ assert messages[0].text == "transport ready"
154
+ assert messages[0].message_id == "message-inbox-1"
155
+ assert empty == []
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_list_agents_returns_online_agent_names(relay_server):
160
+ RelayTransport = _transport_class()
161
+ relay_server.add_agent("Review-Core")
162
+ transport = RelayTransport("TransportTester", relay_server.make_config())
163
+ await transport.connect()
164
+
165
+ try:
166
+ agents = await transport.list_agents()
167
+ finally:
168
+ await transport.disconnect()
169
+
170
+ assert set(agents) == {"Review-Core", "TransportTester"}
171
+
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_websocket_messages_are_decoded_and_delivered_to_callback(relay_server):
175
+ RelayTransport = _transport_class()
176
+ transport = RelayTransport("TransportTester", relay_server.make_config())
177
+
178
+ received: list[Message] = []
179
+ delivered = asyncio.Event()
180
+
181
+ async def on_message(message: Message) -> None:
182
+ received.append(message)
183
+ delivered.set()
184
+
185
+ transport.on_ws_message(on_message)
186
+ await transport.connect()
187
+
188
+ try:
189
+ await relay_server.push_ws_message(
190
+ transport.agent_id,
191
+ sender="Review-Core",
192
+ text="looks good",
193
+ channel="core-py",
194
+ message_id="message-ws-1",
195
+ )
196
+ await asyncio.wait_for(delivered.wait(), timeout=1.0)
197
+ finally:
198
+ await transport.disconnect()
199
+
200
+ assert len(received) == 1
201
+ assert received[0].sender == "Review-Core"
202
+ assert received[0].text == "looks good"
203
+ assert received[0].channel == "core-py"
204
+ assert received[0].message_id == "message-ws-1"
205
+
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_transport_reconnects_after_websocket_disconnect(relay_server, monkeypatch):
209
+ transport_module = _transport_module()
210
+ RelayTransport = transport_module.RelayTransport
211
+ sleep_calls: list[float] = []
212
+
213
+ async def fake_sleep(delay: float) -> None:
214
+ sleep_calls.append(delay)
215
+ await _ORIGINAL_ASYNCIO_SLEEP(0)
216
+
217
+ monkeypatch.setattr(transport_module.asyncio, "sleep", fake_sleep)
218
+
219
+ transport = RelayTransport("TransportTester", relay_server.make_config())
220
+ received: list[Message] = []
221
+ delivered = asyncio.Event()
222
+
223
+ def on_message(message: Message) -> None:
224
+ received.append(message)
225
+ delivered.set()
226
+
227
+ transport.on_ws_message(on_message)
228
+ await transport.connect()
229
+
230
+ try:
231
+ agent_id = transport.agent_id
232
+ await relay_server.close_ws(agent_id)
233
+ await relay_server.wait_for_ws_connections(agent_id, count=2)
234
+
235
+ await relay_server.push_ws_message(
236
+ agent_id,
237
+ sender="Impl-Core",
238
+ text="reconnected",
239
+ message_id="message-reconnect-1",
240
+ )
241
+ await asyncio.wait_for(delivered.wait(), timeout=1.0)
242
+ finally:
243
+ await transport.disconnect()
244
+
245
+ assert received[-1].sender == "Impl-Core"
246
+ assert received[-1].text == "reconnected"
247
+ assert [delay for delay in sleep_calls if delay >= 1][:1] == [1]
248
+
249
+
250
+ @pytest.mark.asyncio
251
+ @pytest.mark.parametrize(
252
+ ("workspace", "api_key", "missing_name"),
253
+ [
254
+ ("test-workspace", None, "RELAY_API_KEY"),
255
+ (None, "test-key", "RELAY_WORKSPACE"),
256
+ ],
257
+ )
258
+ async def test_connect_requires_workspace_and_api_key(
259
+ relay_server,
260
+ monkeypatch,
261
+ workspace,
262
+ api_key,
263
+ missing_name,
264
+ ):
265
+ RelayTransport = _transport_class()
266
+ monkeypatch.delenv("RELAY_WORKSPACE", raising=False)
267
+ monkeypatch.delenv("RELAY_API_KEY", raising=False)
268
+
269
+ transport = RelayTransport(
270
+ "TransportTester",
271
+ relay_server.make_config(workspace=workspace, api_key=api_key),
272
+ )
273
+
274
+ with pytest.raises(RelayConfigError, match=missing_name):
275
+ await transport.connect()
276
+
277
+
278
+ @pytest.mark.asyncio
279
+ async def test_register_agent_raises_relay_auth_error_on_401(relay_server):
280
+ RelayTransport = _transport_class()
281
+ relay_server.queue_http_error("register_agent", status=401, message="Unauthorized")
282
+ transport = RelayTransport("TransportTester", relay_server.make_config())
283
+
284
+ with pytest.raises(RelayAuthError, match="Unauthorized"):
285
+ await transport.register_agent()
286
+
287
+
288
+ @pytest.mark.asyncio
289
+ async def test_send_dm_raises_connection_error_on_client_error(relay_server):
290
+ RelayTransport = _transport_class()
291
+ transport = RelayTransport("TransportTester", relay_server.make_config())
292
+ await transport.connect()
293
+
294
+ try:
295
+ relay_server.queue_http_error("send_dm", status=404, message="Recipient not found")
296
+
297
+ with pytest.raises(RelayConnectionError, match="Recipient not found") as exc_info:
298
+ await transport.send_dm("Missing-Agent", "hello")
299
+ finally:
300
+ await transport.disconnect()
301
+
302
+ assert exc_info.value.status_code == 404
303
+
304
+
305
+ @pytest.mark.asyncio
306
+ async def test_send_dm_retries_transient_server_errors_before_succeeding(
307
+ relay_server,
308
+ monkeypatch,
309
+ ):
310
+ transport_module = _transport_module()
311
+ RelayTransport = transport_module.RelayTransport
312
+ sleep_calls: list[float] = []
313
+
314
+ async def fake_sleep(delay: float) -> None:
315
+ sleep_calls.append(delay)
316
+ await _ORIGINAL_ASYNCIO_SLEEP(0)
317
+
318
+ monkeypatch.setattr(transport_module.asyncio, "sleep", fake_sleep)
319
+
320
+ transport = RelayTransport("TransportTester", relay_server.make_config())
321
+ await transport.connect()
322
+
323
+ try:
324
+ relay_server.queue_http_error(
325
+ "send_dm",
326
+ status=503,
327
+ message="Temporary failure",
328
+ repeat=2,
329
+ )
330
+
331
+ message_id = await transport.send_dm("Review-Core", "retry me")
332
+ finally:
333
+ await transport.disconnect()
334
+
335
+ assert message_id.startswith("message-")
336
+ assert relay_server.request_count("send_dm") == 3
337
+ assert [delay for delay in sleep_calls if delay >= 1][:2] == [1, 2]
338
+
339
+
340
+ @pytest.mark.asyncio
341
+ async def test_send_dm_raises_after_exhausting_server_error_retries(
342
+ relay_server,
343
+ monkeypatch,
344
+ ):
345
+ transport_module = _transport_module()
346
+ RelayTransport = transport_module.RelayTransport
347
+ sleep_calls: list[float] = []
348
+
349
+ async def fake_sleep(delay: float) -> None:
350
+ sleep_calls.append(delay)
351
+ await _ORIGINAL_ASYNCIO_SLEEP(0)
352
+
353
+ monkeypatch.setattr(transport_module.asyncio, "sleep", fake_sleep)
354
+
355
+ transport = RelayTransport("TransportTester", relay_server.make_config())
356
+ await transport.connect()
357
+
358
+ try:
359
+ relay_server.queue_http_error(
360
+ "send_dm",
361
+ status=503,
362
+ message="Still failing",
363
+ repeat=3,
364
+ )
365
+
366
+ with pytest.raises(RelayConnectionError, match="Still failing") as exc_info:
367
+ await transport.send_dm("Review-Core", "retry me")
368
+ finally:
369
+ await transport.disconnect()
370
+
371
+ assert exc_info.value.status_code == 503
372
+ assert relay_server.request_count("send_dm") == 3
373
+ assert [delay for delay in sleep_calls if delay >= 1][:2] == [1, 2]