agent-relay 3.2.15 → 3.2.17

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 (140) 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 +3865 -17179
  6. package/dist/src/cli/commands/setup.d.ts.map +1 -1
  7. package/dist/src/cli/commands/setup.js +2 -0
  8. package/dist/src/cli/commands/setup.js.map +1 -1
  9. package/package.json +8 -8
  10. package/packages/acp-bridge/package.json +2 -2
  11. package/packages/config/package.json +1 -1
  12. package/packages/hooks/package.json +4 -4
  13. package/packages/memory/package.json +2 -2
  14. package/packages/openclaw/package.json +2 -2
  15. package/packages/policy/package.json +2 -2
  16. package/packages/sdk/dist/broker-path.d.ts +19 -0
  17. package/packages/sdk/dist/broker-path.d.ts.map +1 -0
  18. package/packages/sdk/dist/broker-path.js +71 -0
  19. package/packages/sdk/dist/broker-path.js.map +1 -0
  20. package/packages/sdk/dist/cli-registry.d.ts.map +1 -1
  21. package/packages/sdk/dist/cli-registry.js +4 -0
  22. package/packages/sdk/dist/cli-registry.js.map +1 -1
  23. package/packages/sdk/dist/client.d.ts +6 -1
  24. package/packages/sdk/dist/client.d.ts.map +1 -1
  25. package/packages/sdk/dist/client.js +18 -0
  26. package/packages/sdk/dist/client.js.map +1 -1
  27. package/packages/sdk/dist/communicate/adapters/index.d.ts +0 -5
  28. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -1
  29. package/packages/sdk/dist/communicate/adapters/index.js +0 -5
  30. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -1
  31. package/packages/sdk/dist/communicate/adapters/pi.d.ts +0 -1
  32. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -1
  33. package/packages/sdk/dist/communicate/adapters/pi.js +0 -4
  34. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -1
  35. package/packages/sdk/dist/communicate/core.d.ts.map +1 -1
  36. package/packages/sdk/dist/communicate/core.js +2 -3
  37. package/packages/sdk/dist/communicate/core.js.map +1 -1
  38. package/packages/sdk/dist/communicate/index.d.ts +17 -1
  39. package/packages/sdk/dist/communicate/index.d.ts.map +1 -1
  40. package/packages/sdk/dist/communicate/index.js +40 -1
  41. package/packages/sdk/dist/communicate/index.js.map +1 -1
  42. package/packages/sdk/dist/communicate/transport.d.ts +0 -1
  43. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -1
  44. package/packages/sdk/dist/communicate/transport.js +42 -134
  45. package/packages/sdk/dist/communicate/transport.js.map +1 -1
  46. package/packages/sdk/dist/http.d.ts +38 -0
  47. package/packages/sdk/dist/http.d.ts.map +1 -0
  48. package/packages/sdk/dist/http.js +60 -0
  49. package/packages/sdk/dist/http.js.map +1 -0
  50. package/packages/sdk/dist/protocol.d.ts +25 -0
  51. package/packages/sdk/dist/protocol.d.ts.map +1 -1
  52. package/packages/sdk/dist/relay.d.ts +26 -3
  53. package/packages/sdk/dist/relay.d.ts.map +1 -1
  54. package/packages/sdk/dist/relay.js +62 -4
  55. package/packages/sdk/dist/relay.js.map +1 -1
  56. package/packages/sdk/dist/workflows/api-executor.d.ts +16 -0
  57. package/packages/sdk/dist/workflows/api-executor.d.ts.map +1 -0
  58. package/packages/sdk/dist/workflows/api-executor.js +94 -0
  59. package/packages/sdk/dist/workflows/api-executor.js.map +1 -0
  60. package/packages/sdk/dist/workflows/builder.d.ts +14 -0
  61. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  62. package/packages/sdk/dist/workflows/builder.js +26 -0
  63. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  64. package/packages/sdk/dist/workflows/cloud-runner.d.ts +15 -0
  65. package/packages/sdk/dist/workflows/cloud-runner.d.ts.map +1 -0
  66. package/packages/sdk/dist/workflows/cloud-runner.js +41 -0
  67. package/packages/sdk/dist/workflows/cloud-runner.js.map +1 -0
  68. package/packages/sdk/dist/workflows/index.d.ts +2 -0
  69. package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
  70. package/packages/sdk/dist/workflows/index.js +1 -0
  71. package/packages/sdk/dist/workflows/index.js.map +1 -1
  72. package/packages/sdk/dist/workflows/run.d.ts.map +1 -1
  73. package/packages/sdk/dist/workflows/run.js +4 -0
  74. package/packages/sdk/dist/workflows/run.js.map +1 -1
  75. package/packages/sdk/dist/workflows/runner.d.ts +14 -0
  76. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  77. package/packages/sdk/dist/workflows/runner.js +169 -28
  78. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  79. package/packages/sdk/dist/workflows/types.d.ts +13 -3
  80. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  81. package/packages/sdk/dist/workflows/types.js +5 -1
  82. package/packages/sdk/dist/workflows/types.js.map +1 -1
  83. package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
  84. package/packages/sdk/dist/workflows/validator.js +12 -0
  85. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  86. package/packages/sdk/package.json +13 -3
  87. package/packages/sdk/src/__tests__/channel-management.test.ts +131 -0
  88. package/packages/sdk/src/__tests__/communicate/core.test.ts +36 -88
  89. package/packages/sdk/src/__tests__/communicate/transport.test.ts +41 -80
  90. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +120 -0
  91. package/packages/sdk/src/__tests__/relay-channel-ops.test.ts +121 -0
  92. package/packages/sdk/src/broker-path.ts +74 -0
  93. package/packages/sdk/src/cli-registry.ts +4 -0
  94. package/packages/sdk/src/client.ts +28 -0
  95. package/packages/sdk/src/communicate/adapters/index.ts +0 -5
  96. package/packages/sdk/src/communicate/adapters/pi.ts +1 -5
  97. package/packages/sdk/src/communicate/core.ts +6 -10
  98. package/packages/sdk/src/communicate/index.ts +57 -1
  99. package/packages/sdk/src/communicate/transport.ts +46 -177
  100. package/packages/sdk/src/http.ts +96 -0
  101. package/packages/sdk/src/protocol.ts +24 -0
  102. package/packages/sdk/src/relay.ts +93 -8
  103. package/packages/sdk/src/workflows/README.md +5 -2
  104. package/packages/sdk/src/workflows/api-executor.ts +108 -0
  105. package/packages/sdk/src/workflows/builder.ts +40 -0
  106. package/packages/sdk/src/workflows/cloud-runner.ts +56 -0
  107. package/packages/sdk/src/workflows/index.ts +2 -0
  108. package/packages/sdk/src/workflows/run.ts +5 -0
  109. package/packages/sdk/src/workflows/runner.ts +197 -30
  110. package/packages/sdk/src/workflows/types.ts +19 -4
  111. package/packages/sdk/src/workflows/validator.ts +15 -0
  112. package/packages/sdk-py/README.md +7 -0
  113. package/packages/sdk-py/pyproject.toml +1 -1
  114. package/packages/sdk-py/src/agent_relay/__init__.py +2 -0
  115. package/packages/sdk-py/src/agent_relay/builder.py +64 -7
  116. package/packages/sdk-py/src/agent_relay/client.py +4 -0
  117. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +0 -9
  118. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +5 -9
  119. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +5 -7
  120. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +3 -13
  121. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +5 -2
  122. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +5 -9
  123. package/packages/sdk-py/src/agent_relay/communicate/core.py +7 -24
  124. package/packages/sdk-py/src/agent_relay/communicate/transport.py +35 -212
  125. package/packages/sdk-py/src/agent_relay/communicate/types.py +1 -1
  126. package/packages/sdk-py/src/agent_relay/protocol.py +1 -0
  127. package/packages/sdk-py/src/agent_relay/relay.py +9 -1
  128. package/packages/sdk-py/src/agent_relay/types.py +1 -0
  129. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +6 -6
  130. package/packages/sdk-py/tests/communicate/conftest.py +86 -233
  131. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +2 -2
  132. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +14 -24
  133. package/packages/sdk-py/tests/communicate/test_transport.py +65 -54
  134. package/packages/sdk-py/tests/test_builder.py +58 -0
  135. package/packages/sdk-py/tests/test_dry_run.py +215 -0
  136. package/packages/sdk-py/tests/test_send_message_mode.py +91 -0
  137. package/packages/telemetry/package.json +1 -1
  138. package/packages/trajectory/package.json +2 -2
  139. package/packages/user-directory/package.json +2 -2
  140. package/packages/utils/package.json +2 -2
@@ -48,7 +48,7 @@ def mock_options():
48
48
  def test_on_relay_injects_mcp_server(mock_relay, mock_options):
49
49
  adapter = _adapter_module()
50
50
 
51
- result = adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
51
+ result = adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
52
52
 
53
53
  assert result is mock_options
54
54
  assert len(mock_options.mcp_servers) == 1
@@ -66,7 +66,7 @@ async def test_post_tool_use_hook_drains_inbox(mock_relay, mock_options):
66
66
  Message(sender="Other", text="Hello", message_id="1")
67
67
  ]
68
68
 
69
- adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
69
+ adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
70
70
  post_tool_use = mock_options.hooks.post_tool_use
71
71
 
72
72
  assert post_tool_use is not None
@@ -84,7 +84,7 @@ async def test_post_tool_use_hook_returns_none_if_inbox_empty(mock_relay, mock_o
84
84
  adapter = _adapter_module()
85
85
  mock_relay.inbox.return_value = []
86
86
 
87
- adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
87
+ adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
88
88
  hook_result = await mock_options.hooks.post_tool_use()
89
89
 
90
90
  assert hook_result is None
@@ -98,7 +98,7 @@ async def test_stop_hook_drains_inbox_and_continues(mock_relay, mock_options):
98
98
  Message(sender="Other", text="Wait!", message_id="2")
99
99
  ]
100
100
 
101
- adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
101
+ adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
102
102
  stop_hook = mock_options.hooks.stop
103
103
 
104
104
  assert stop_hook is not None
@@ -114,7 +114,7 @@ async def test_stop_hook_returns_none_if_inbox_empty(mock_relay, mock_options):
114
114
  adapter = _adapter_module()
115
115
  mock_relay.inbox.return_value = []
116
116
 
117
- adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
117
+ adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
118
118
  hook_result = await mock_options.hooks.stop()
119
119
 
120
120
  assert hook_result is None
@@ -126,7 +126,7 @@ async def test_hooks_chaining(mock_relay, mock_options):
126
126
  original_post_tool_use = AsyncMock(return_value=MagicMock(system_message="Original"))
127
127
  mock_options.hooks.post_tool_use = original_post_tool_use
128
128
 
129
- adapter.on_relay("TestAgent", mock_options, relay=mock_relay)
129
+ adapter.on_relay(mock_options, relay=mock_relay, name="TestAgent")
130
130
 
131
131
  # When inbox is empty, it should return original result
132
132
  mock_relay.inbox.return_value = []
@@ -16,20 +16,7 @@ from agent_relay.communicate.types import RelayConfig
16
16
 
17
17
 
18
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
- """
19
+ """Minimal in-process Relaycast mock for transport and integration tests."""
33
20
 
34
21
  def __init__(self, *, api_key: str = "test-key", workspace: str = "test-workspace") -> None:
35
22
  self.api_key = api_key
@@ -44,27 +31,20 @@ class MockRelayServer:
44
31
  self.received_ws_messages: list[dict[str, Any]] = []
45
32
  self.ws_connection_counts: dict[str, int] = defaultdict(int)
46
33
 
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
34
  self._active_websockets: dict[str, web.WebSocketResponse] = {}
53
35
  self._queued_errors: dict[str, deque[tuple[int, dict[str, Any]]]] = defaultdict(deque)
54
36
  self._agent_ids = count(1)
55
37
  self._message_ids = count(1)
56
38
 
57
39
  self._app = web.Application()
58
- self._app.router.add_post("/v1/agents", self._handle_register)
40
+ self._app.router.add_post("/v1/agents/register", self._handle_register)
41
+ self._app.router.add_delete("/v1/agents/{agent_id}", self._handle_unregister)
42
+ self._app.router.add_post("/v1/messages/dm", self._handle_dm)
43
+ self._app.router.add_post("/v1/messages/channel", self._handle_channel)
44
+ self._app.router.add_post("/v1/messages/reply", self._handle_reply)
45
+ self._app.router.add_get("/v1/inbox/{agent_id}", self._handle_inbox)
59
46
  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)
47
+ self._app.router.add_get("/v1/ws/{agent_id}", self._handle_ws)
68
48
 
69
49
  self._runner: web.AppRunner | None = None
70
50
  self._site: web.TCPSite | None = None
@@ -89,7 +69,7 @@ class MockRelayServer:
89
69
  message: str,
90
70
  repeat: int = 1,
91
71
  ) -> None:
92
- body = {"ok": False, "error": {"code": "error", "message": message}}
72
+ body = {"message": message}
93
73
  for _ in range(repeat):
94
74
  self._queued_errors[operation].append((status, body))
95
75
 
@@ -121,30 +101,15 @@ class MockRelayServer:
121
101
  message_id: str | None = None,
122
102
  timestamp: float | None = None,
123
103
  ) -> dict[str, Any]:
124
- msg_id = message_id or f"message-{next(self._message_ids)}"
125
104
  payload = {
126
105
  "sender": sender,
127
106
  "text": text,
128
107
  "channel": channel,
129
108
  "thread_id": thread_id,
130
- "message_id": msg_id,
109
+ "message_id": message_id or f"message-{next(self._message_ids)}",
131
110
  "timestamp": timestamp,
132
111
  }
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
- })
112
+ self.inboxes[agent_id].append(payload)
148
113
  return payload
149
114
 
150
115
  async def push_ws_message(
@@ -162,19 +127,15 @@ class MockRelayServer:
162
127
  if ws is None or ws.closed:
163
128
  raise AssertionError(f"No active websocket for agent {agent_id!r}")
164
129
 
165
- payload: dict[str, Any] = {
166
- "type": "dm.received" if channel is None else "message.created",
167
- "agent_name": sender,
130
+ payload = {
131
+ "type": "message",
132
+ "sender": sender,
168
133
  "text": text,
169
- "id": message_id or f"message-{next(self._message_ids)}",
134
+ "channel": channel,
135
+ "thread_id": thread_id,
136
+ "message_id": message_id or f"message-{next(self._message_ids)}",
137
+ "timestamp": timestamp,
170
138
  }
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
139
  await ws.send_json(payload)
179
140
  return payload
180
141
 
@@ -228,195 +189,130 @@ class MockRelayServer:
228
189
  if self._runner is not None:
229
190
  await self._runner.cleanup()
230
191
 
231
- # --- Handlers ---
232
-
233
192
  async def _handle_register(self, request: web.Request) -> web.StreamResponse:
234
193
  payload = await request.json()
235
194
  self._record_request("register_agent", request, payload)
236
195
 
237
196
  if error := self._pop_error("register_agent"):
238
197
  return error
239
- if error := self._require_workspace_auth(request):
198
+ if error := self._require_auth(request):
240
199
  return error
241
200
 
242
201
  agent_id = f"agent-{next(self._agent_ids)}"
243
202
  token = f"token-{agent_id}"
244
203
  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)
204
+ return web.json_response({"agent_id": agent_id, "token": token})
205
+
206
+ async def _handle_unregister(self, request: web.Request) -> web.StreamResponse:
207
+ agent_id = request.match_info["agent_id"]
253
208
  self._record_request("unregister_agent", request, {"agent_id": agent_id})
254
209
 
255
210
  if error := self._pop_error("unregister_agent"):
256
211
  return error
257
- if agent_id is None:
258
- return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
212
+ if error := self._require_auth(request):
213
+ return error
259
214
 
260
215
  self.registered_agents.pop(agent_id, None)
261
216
  self.inboxes.pop(agent_id, None)
262
217
  ws = self._active_websockets.pop(agent_id, None)
263
218
  if ws is not None and not ws.closed:
264
219
  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})
220
+ return web.Response(status=204)
273
221
 
274
222
  async def _handle_dm(self, request: web.Request) -> web.StreamResponse:
275
223
  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
224
  self._record_request("send_dm", request, payload)
279
225
 
280
226
  if error := self._pop_error("send_dm"):
281
227
  return error
282
- if agent_id is None:
283
- return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
228
+ if error := self._require_auth(request):
229
+ return error
284
230
 
285
231
  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
-
232
+ self.messages.append({"kind": "dm", "message_id": message_id, **payload})
306
233
  await self._deliver_to_agents(
307
- recipient_ids,
308
- sender=sender_name,
234
+ self.find_agent_ids(payload["to"]),
235
+ sender=payload["from"],
309
236
  text=payload["text"],
310
237
  message_id=message_id,
311
238
  )
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})
239
+ return web.json_response({"message_id": message_id})
322
240
 
323
241
  async def _handle_channel(self, request: web.Request) -> web.StreamResponse:
324
- channel = request.match_info["channel"]
325
242
  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})
243
+ self._record_request("post_message", request, payload)
329
244
 
330
245
  if error := self._pop_error("post_message"):
331
246
  return error
332
- if agent_id is None:
333
- return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
247
+ if error := self._require_auth(request):
248
+ return error
334
249
 
335
250
  message_id = f"message-{next(self._message_ids)}"
336
- self.messages.append({"kind": "channel", "message_id": message_id, "channel": channel, **payload, "from": sender_name})
251
+ self.messages.append({"kind": "channel", "message_id": message_id, **payload})
337
252
  await self._deliver_to_agents(
338
253
  [
339
- aid
340
- for aid, registration in self.registered_agents.items()
341
- if aid != agent_id
254
+ agent_id
255
+ for agent_id, registration in self.registered_agents.items()
256
+ if registration["name"] != payload["from"]
342
257
  ],
343
- sender=sender_name,
258
+ sender=payload["from"],
344
259
  text=payload["text"],
345
- channel=channel,
260
+ channel=payload["channel"],
346
261
  message_id=message_id,
347
262
  )
348
- return web.json_response({
349
- "ok": True,
350
- "data": {"id": message_id, "channel_name": channel, "text": payload["text"]},
351
- }, status=201)
263
+ return web.json_response({"message_id": message_id})
352
264
 
353
265
  async def _handle_reply(self, request: web.Request) -> web.StreamResponse:
354
- parent_id = request.match_info["message_id"]
355
266
  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})
267
+ self._record_request("reply", request, payload)
359
268
 
360
269
  if error := self._pop_error("reply"):
361
270
  return error
362
- if agent_id is None:
363
- return web.json_response({"ok": False, "error": {"message": "Unauthorized"}}, status=401)
271
+ if error := self._require_auth(request):
272
+ return error
364
273
 
365
274
  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)
275
+ self.messages.append(
276
+ {
277
+ "kind": "reply",
278
+ "reply_id": reply_id,
279
+ "thread_id": payload.get("thread_id") or payload.get("message_id"),
280
+ **payload,
281
+ }
282
+ )
283
+ return web.json_response({"message_id": reply_id})
377
284
 
378
285
  async def _handle_inbox(self, request: web.Request) -> web.StreamResponse:
379
- agent_id = self._resolve_agent_from_token(request)
286
+ agent_id = request.match_info["agent_id"]
380
287
  self._record_request("check_inbox", request, {"agent_id": agent_id})
381
288
 
382
289
  if error := self._pop_error("check_inbox"):
383
290
  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
- })
291
+ if error := self._require_auth(request):
292
+ return error
293
+
294
+ messages = list(self.inboxes[agent_id])
295
+ self.inboxes[agent_id].clear()
296
+ return web.json_response({"messages": messages})
398
297
 
399
298
  async def _handle_agents(self, request: web.Request) -> web.StreamResponse:
400
299
  self._record_request("list_agents", request, None)
401
300
 
402
301
  if error := self._pop_error("list_agents"):
403
302
  return error
404
- if error := self._require_workspace_auth(request):
303
+ if error := self._require_auth(request):
405
304
  return error
406
305
 
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})
306
+ agents = sorted(
307
+ {agent["name"] for agent in self.registered_agents.values()} | self.extra_agents
308
+ )
309
+ return web.json_response({"agents": agents})
415
310
 
416
311
  async def _handle_ws(self, request: web.Request) -> web.StreamResponse:
312
+ agent_id = request.match_info["agent_id"]
417
313
  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:
314
+ registration = self.registered_agents.get(agent_id)
315
+ if registration is None or token != registration["token"]:
420
316
  raise web.HTTPUnauthorized(text="Invalid websocket token")
421
317
 
422
318
  ws = web.WebSocketResponse()
@@ -437,8 +333,6 @@ class MockRelayServer:
437
333
 
438
334
  return ws
439
335
 
440
- # --- Helpers ---
441
-
442
336
  def _record_request(
443
337
  self,
444
338
  operation: str,
@@ -460,23 +354,11 @@ class MockRelayServer:
460
354
  status, body = self._queued_errors[operation].popleft()
461
355
  return web.json_response(body, status=status)
462
356
 
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:
357
+ def _require_auth(self, request: web.Request) -> web.StreamResponse | None:
358
+ auth_header = request.headers.get("Authorization")
359
+ if auth_header == f"Bearer {self.api_key}":
472
360
  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)
361
+ return web.json_response({"message": "Unauthorized"}, status=401)
480
362
 
481
363
  async def _deliver_to_agents(
482
364
  self,
@@ -492,57 +374,28 @@ class MockRelayServer:
492
374
  if not agent_ids:
493
375
  return
494
376
 
377
+ payload = {
378
+ "sender": sender,
379
+ "text": text,
380
+ "channel": channel,
381
+ "thread_id": thread_id,
382
+ "message_id": message_id or f"message-{next(self._message_ids)}",
383
+ "timestamp": timestamp,
384
+ }
385
+
495
386
  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)
387
+ await self._deliver_message(agent_id, payload)
499
388
 
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:
389
+ async def _deliver_message(self, agent_id: str, payload: dict[str, Any]) -> None:
511
390
  ws = self._active_websockets.get(agent_id)
512
391
  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
392
  try:
526
- await ws.send_json(payload)
393
+ await ws.send_json({"type": "message", **payload})
527
394
  return
528
395
  except Exception:
529
396
  self._active_websockets.pop(agent_id, None)
530
397
 
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
- })
398
+ self.inboxes[agent_id].append(dict(payload))
546
399
 
547
400
 
548
401
  @pytest_asyncio.fixture
@@ -218,7 +218,7 @@ async def test_swarms_sender_reaches_claude_sdk_hook_system_message(relay_server
218
218
  receiver_options = SimpleNamespace(mcp_servers=[], hooks=Hooks())
219
219
 
220
220
  swarms_adapter.on_relay(sender_agent, sender_relay)
221
- claude_adapter.on_relay("ClaudeReceiver", receiver_options, relay=receiver_relay)
221
+ claude_adapter.on_relay(receiver_options, relay=receiver_relay, name="ClaudeReceiver")
222
222
 
223
223
  try:
224
224
  await _prime_claude_receiver(
@@ -266,7 +266,7 @@ async def test_multiple_framework_agents_post_to_the_same_channel(relay_server,
266
266
  openai_adapter.on_relay(openai_agent, openai_relay)
267
267
  google_adapter.on_relay(google_agent, google_relay)
268
268
  swarms_adapter.on_relay(swarms_agent, swarms_relay)
269
- claude_adapter.on_relay("ClaudeChannelReader", receiver_options, relay=receiver_relay)
269
+ claude_adapter.on_relay(receiver_options, relay=receiver_relay, name="ClaudeChannelReader")
270
270
 
271
271
  try:
272
272
  await _prime_claude_receiver(
@@ -64,30 +64,16 @@ async def _wait_for_agent_absent(
64
64
  transport: RelayTransport,
65
65
  agent_name: str,
66
66
  *,
67
- timeout: float = 30.0,
67
+ timeout: float = 15.0,
68
68
  ) -> None:
69
- """Wait for an agent to disappear from list_agents or go offline.
70
-
71
- Relaycast presence may take a few seconds to propagate after disconnect.
72
- We check both absence from the list and offline status.
73
- """
74
69
  deadline = asyncio.get_running_loop().time() + timeout
75
70
 
76
71
  while asyncio.get_running_loop().time() < deadline:
77
- agents_payload = await transport.send_http("GET", "/v1/agents")
78
- data = agents_payload.get("data", agents_payload)
79
- if isinstance(data, list):
80
- matching = [a for a in data if isinstance(a, dict) and a.get("name") == agent_name]
81
- if not matching:
82
- return
83
- # Also accept "offline" status
84
- if all(a.get("status") == "offline" for a in matching):
85
- return
86
- elif agent_name not in await transport.list_agents():
72
+ if agent_name not in await transport.list_agents():
87
73
  return
88
- await asyncio.sleep(0.5)
74
+ await asyncio.sleep(0.25)
89
75
 
90
- raise AssertionError(f"Timed out waiting for {agent_name!r} to go offline/absent from list_agents().")
76
+ raise AssertionError(f"Timed out waiting for {agent_name!r} to disappear from list_agents().")
91
77
 
92
78
 
93
79
  @pytest.mark.asyncio
@@ -110,14 +96,9 @@ async def test_register_send_receive_inbox_and_unregister_round_trip():
110
96
  assert message.text == text
111
97
  assert message.sender == sender.agent_name
112
98
 
113
- # Verify disconnect completes without error
114
99
  receiver_name = receiver.agent_name
115
100
  await receiver.disconnect()
116
-
117
- # Note: Relaycast presence updates are eventually consistent —
118
- # agents may remain "online" in list_agents for a heartbeat window
119
- # after disconnect. We verify the disconnect call succeeds rather
120
- # than waiting for presence propagation.
101
+ await _wait_for_agent_absent(probe, receiver_name)
121
102
  finally:
122
103
  await asyncio.gather(
123
104
  _safe_disconnect(sender),
@@ -125,6 +106,15 @@ async def test_register_send_receive_inbox_and_unregister_round_trip():
125
106
  _safe_disconnect(probe),
126
107
  )
127
108
 
109
+ cleanup_probe = RelayTransport(_unique_name("sdk-py-e2e-cleanup"), config)
110
+ await cleanup_probe.connect()
111
+ try:
112
+ agents = await cleanup_probe.list_agents()
113
+ assert sender.agent_name not in agents
114
+ assert receiver.agent_name not in agents
115
+ finally:
116
+ await _safe_disconnect(cleanup_probe)
117
+
128
118
 
129
119
  @pytest.mark.asyncio
130
120
  async def test_two_agents_can_exchange_bidirectional_messages():