agent-relay 3.2.14 → 3.2.16

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 (137) 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 +3859 -17164
  6. package/package.json +8 -8
  7. package/packages/acp-bridge/package.json +2 -2
  8. package/packages/config/package.json +1 -1
  9. package/packages/hooks/package.json +4 -4
  10. package/packages/memory/package.json +2 -2
  11. package/packages/openclaw/package.json +2 -2
  12. package/packages/policy/package.json +2 -2
  13. package/packages/sdk/dist/broker-path.d.ts +19 -0
  14. package/packages/sdk/dist/broker-path.d.ts.map +1 -0
  15. package/packages/sdk/dist/broker-path.js +71 -0
  16. package/packages/sdk/dist/broker-path.js.map +1 -0
  17. package/packages/sdk/dist/cli-registry.d.ts.map +1 -1
  18. package/packages/sdk/dist/cli-registry.js +10 -1
  19. package/packages/sdk/dist/cli-registry.js.map +1 -1
  20. package/packages/sdk/dist/client.d.ts +6 -1
  21. package/packages/sdk/dist/client.d.ts.map +1 -1
  22. package/packages/sdk/dist/client.js +18 -0
  23. package/packages/sdk/dist/client.js.map +1 -1
  24. package/packages/sdk/dist/communicate/adapters/index.d.ts +0 -5
  25. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -1
  26. package/packages/sdk/dist/communicate/adapters/index.js +0 -5
  27. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -1
  28. package/packages/sdk/dist/communicate/adapters/pi.d.ts +0 -1
  29. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -1
  30. package/packages/sdk/dist/communicate/adapters/pi.js +0 -4
  31. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -1
  32. package/packages/sdk/dist/communicate/core.d.ts.map +1 -1
  33. package/packages/sdk/dist/communicate/core.js +2 -3
  34. package/packages/sdk/dist/communicate/core.js.map +1 -1
  35. package/packages/sdk/dist/communicate/index.d.ts +17 -1
  36. package/packages/sdk/dist/communicate/index.d.ts.map +1 -1
  37. package/packages/sdk/dist/communicate/index.js +40 -1
  38. package/packages/sdk/dist/communicate/index.js.map +1 -1
  39. package/packages/sdk/dist/communicate/transport.d.ts +0 -1
  40. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -1
  41. package/packages/sdk/dist/communicate/transport.js +42 -134
  42. package/packages/sdk/dist/communicate/transport.js.map +1 -1
  43. package/packages/sdk/dist/http.d.ts +38 -0
  44. package/packages/sdk/dist/http.d.ts.map +1 -0
  45. package/packages/sdk/dist/http.js +60 -0
  46. package/packages/sdk/dist/http.js.map +1 -0
  47. package/packages/sdk/dist/protocol.d.ts +25 -0
  48. package/packages/sdk/dist/protocol.d.ts.map +1 -1
  49. package/packages/sdk/dist/relay.d.ts +26 -3
  50. package/packages/sdk/dist/relay.d.ts.map +1 -1
  51. package/packages/sdk/dist/relay.js +62 -4
  52. package/packages/sdk/dist/relay.js.map +1 -1
  53. package/packages/sdk/dist/workflows/api-executor.d.ts +16 -0
  54. package/packages/sdk/dist/workflows/api-executor.d.ts.map +1 -0
  55. package/packages/sdk/dist/workflows/api-executor.js +94 -0
  56. package/packages/sdk/dist/workflows/api-executor.js.map +1 -0
  57. package/packages/sdk/dist/workflows/builder.d.ts +14 -0
  58. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  59. package/packages/sdk/dist/workflows/builder.js +26 -0
  60. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  61. package/packages/sdk/dist/workflows/cloud-runner.d.ts +15 -0
  62. package/packages/sdk/dist/workflows/cloud-runner.d.ts.map +1 -0
  63. package/packages/sdk/dist/workflows/cloud-runner.js +41 -0
  64. package/packages/sdk/dist/workflows/cloud-runner.js.map +1 -0
  65. package/packages/sdk/dist/workflows/index.d.ts +2 -0
  66. package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
  67. package/packages/sdk/dist/workflows/index.js +1 -0
  68. package/packages/sdk/dist/workflows/index.js.map +1 -1
  69. package/packages/sdk/dist/workflows/run.d.ts.map +1 -1
  70. package/packages/sdk/dist/workflows/run.js +4 -0
  71. package/packages/sdk/dist/workflows/run.js.map +1 -1
  72. package/packages/sdk/dist/workflows/runner.d.ts +14 -0
  73. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  74. package/packages/sdk/dist/workflows/runner.js +154 -10
  75. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  76. package/packages/sdk/dist/workflows/types.d.ts +13 -3
  77. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  78. package/packages/sdk/dist/workflows/types.js +5 -1
  79. package/packages/sdk/dist/workflows/types.js.map +1 -1
  80. package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
  81. package/packages/sdk/dist/workflows/validator.js +12 -0
  82. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  83. package/packages/sdk/package.json +13 -3
  84. package/packages/sdk/src/__tests__/channel-management.test.ts +131 -0
  85. package/packages/sdk/src/__tests__/communicate/core.test.ts +36 -88
  86. package/packages/sdk/src/__tests__/communicate/transport.test.ts +41 -80
  87. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +120 -0
  88. package/packages/sdk/src/__tests__/relay-channel-ops.test.ts +121 -0
  89. package/packages/sdk/src/__tests__/workflow-runner.test.ts +2 -2
  90. package/packages/sdk/src/broker-path.ts +74 -0
  91. package/packages/sdk/src/cli-registry.ts +10 -1
  92. package/packages/sdk/src/client.ts +28 -0
  93. package/packages/sdk/src/communicate/adapters/index.ts +0 -5
  94. package/packages/sdk/src/communicate/adapters/pi.ts +1 -5
  95. package/packages/sdk/src/communicate/core.ts +6 -10
  96. package/packages/sdk/src/communicate/index.ts +57 -1
  97. package/packages/sdk/src/communicate/transport.ts +46 -177
  98. package/packages/sdk/src/http.ts +96 -0
  99. package/packages/sdk/src/protocol.ts +24 -0
  100. package/packages/sdk/src/relay.ts +93 -8
  101. package/packages/sdk/src/workflows/README.md +5 -2
  102. package/packages/sdk/src/workflows/api-executor.ts +108 -0
  103. package/packages/sdk/src/workflows/builder.ts +40 -0
  104. package/packages/sdk/src/workflows/cloud-runner.ts +56 -0
  105. package/packages/sdk/src/workflows/index.ts +2 -0
  106. package/packages/sdk/src/workflows/run.ts +5 -0
  107. package/packages/sdk/src/workflows/runner.ts +181 -11
  108. package/packages/sdk/src/workflows/types.ts +19 -4
  109. package/packages/sdk/src/workflows/validator.ts +15 -0
  110. package/packages/sdk-py/README.md +7 -0
  111. package/packages/sdk-py/pyproject.toml +1 -1
  112. package/packages/sdk-py/src/agent_relay/__init__.py +2 -0
  113. package/packages/sdk-py/src/agent_relay/client.py +4 -0
  114. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +0 -9
  115. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +5 -9
  116. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +5 -7
  117. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +3 -13
  118. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +5 -2
  119. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +5 -9
  120. package/packages/sdk-py/src/agent_relay/communicate/core.py +7 -24
  121. package/packages/sdk-py/src/agent_relay/communicate/transport.py +35 -212
  122. package/packages/sdk-py/src/agent_relay/communicate/types.py +1 -1
  123. package/packages/sdk-py/src/agent_relay/protocol.py +1 -0
  124. package/packages/sdk-py/src/agent_relay/relay.py +9 -1
  125. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +6 -6
  126. package/packages/sdk-py/tests/communicate/conftest.py +86 -233
  127. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +2 -2
  128. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +14 -24
  129. package/packages/sdk-py/tests/communicate/test_transport.py +65 -54
  130. package/packages/sdk-py/tests/test_send_message_mode.py +91 -0
  131. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +323 -0
  132. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserverTypes.swift +143 -0
  133. package/packages/sdk-swift/Tests/AgentRelaySDKTests/RelayObserverTests.swift +526 -0
  134. package/packages/telemetry/package.json +1 -1
  135. package/packages/trajectory/package.json +2 -2
  136. package/packages/user-directory/package.json +2 -2
  137. package/packages/utils/package.json +2 -2
@@ -1,4 +1,4 @@
1
- """Tests for the RelayTransport HTTP/WS client against real Relaycast API surface."""
1
+ """Wave 1.2 tests for the RelayTransport HTTP/WS client."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -44,8 +44,10 @@ async def test_register_agent_and_unregister_agent_manage_identity(relay_server)
44
44
 
45
45
  assert transport.agent_id in relay_server.registered_agents
46
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"
47
+ assert relay_server.requests["register_agent"][-1]["json"] == {
48
+ "name": "TransportTester",
49
+ "workspace": relay_server.workspace,
50
+ }
49
51
 
50
52
  agent_id = transport.agent_id
51
53
  await transport.unregister_agent()
@@ -75,55 +77,47 @@ async def test_connect_and_disconnect_manage_registration_and_websocket(relay_se
75
77
 
76
78
 
77
79
  @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):
80
+ @pytest.mark.parametrize(
81
+ ("method_name", "args", "operation", "expected_payload"),
82
+ [
83
+ (
84
+ "send_dm",
85
+ ("Review-Core", "hello"),
86
+ "send_dm",
87
+ {"to": "Review-Core", "text": "hello", "from": "TransportTester"},
88
+ ),
89
+ (
90
+ "post_message",
91
+ ("core-py", "status update"),
92
+ "post_message",
93
+ {"channel": "core-py", "text": "status update", "from": "TransportTester"},
94
+ ),
95
+ (
96
+ "reply",
97
+ ("message-123", "thread reply"),
98
+ "reply",
99
+ {"message_id": "message-123", "text": "thread reply", "from": "TransportTester"},
100
+ ),
101
+ ],
102
+ )
103
+ async def test_send_methods_use_expected_http_payload(
104
+ relay_server,
105
+ method_name,
106
+ args,
107
+ operation,
108
+ expected_payload,
109
+ ):
114
110
  RelayTransport = _transport_class()
115
111
  transport = RelayTransport("TransportTester", relay_server.make_config())
116
112
  await transport.connect()
117
113
 
118
114
  try:
119
- message_id = await transport.reply("message-123", "thread reply")
115
+ message_id = await getattr(transport, method_name)(*args)
120
116
  finally:
121
117
  await transport.disconnect()
122
118
 
123
119
  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"]
120
+ assert relay_server.requests[operation][-1]["json"] == expected_payload
127
121
 
128
122
 
129
123
  @pytest.mark.asyncio
@@ -148,10 +142,16 @@ async def test_check_inbox_returns_message_objects_and_drains_server_inbox(relay
148
142
  finally:
149
143
  await transport.disconnect()
150
144
 
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"
145
+ assert messages == [
146
+ Message(
147
+ sender=queued["sender"],
148
+ text=queued["text"],
149
+ channel=queued["channel"],
150
+ thread_id=queued["thread_id"],
151
+ timestamp=queued["timestamp"],
152
+ message_id=queued["message_id"],
153
+ )
154
+ ]
155
155
  assert empty == []
156
156
 
157
157
 
@@ -197,11 +197,16 @@ async def test_websocket_messages_are_decoded_and_delivered_to_callback(relay_se
197
197
  finally:
198
198
  await transport.disconnect()
199
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"
200
+ assert received == [
201
+ Message(
202
+ sender="Review-Core",
203
+ text="looks good",
204
+ channel="core-py",
205
+ thread_id=None,
206
+ timestamp=None,
207
+ message_id="message-ws-1",
208
+ )
209
+ ]
205
210
 
206
211
 
207
212
  @pytest.mark.asyncio
@@ -242,8 +247,14 @@ async def test_transport_reconnects_after_websocket_disconnect(relay_server, mon
242
247
  finally:
243
248
  await transport.disconnect()
244
249
 
245
- assert received[-1].sender == "Impl-Core"
246
- assert received[-1].text == "reconnected"
250
+ assert received[-1] == Message(
251
+ sender="Impl-Core",
252
+ text="reconnected",
253
+ channel=None,
254
+ thread_id=None,
255
+ timestamp=None,
256
+ message_id="message-reconnect-1",
257
+ )
247
258
  assert [delay for delay in sleep_calls if delay >= 1][:1] == [1]
248
259
 
249
260
 
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+
7
+ from agent_relay.client import AgentRelayClient
8
+ from agent_relay.relay import AgentRelay, HumanHandle
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_client_send_message_includes_mode_in_payload():
13
+ client = AgentRelayClient(binary_path="agent-relay-broker")
14
+ client.start_client = AsyncMock()
15
+
16
+ payloads: list[dict] = []
17
+
18
+ async def fake_request_ok(type_: str, payload: dict):
19
+ assert type_ == "send_message"
20
+ payloads.append(payload)
21
+ return {"event_id": "evt-1", "targets": ["Worker"]}
22
+
23
+ client._request_ok = fake_request_ok # type: ignore[method-assign]
24
+
25
+ result = await client.send_message(
26
+ to="Worker",
27
+ text="hello",
28
+ from_="system",
29
+ thread_id="thread-1",
30
+ priority=5,
31
+ data={"k": "v"},
32
+ mode="steer",
33
+ )
34
+
35
+ assert result["event_id"] == "evt-1"
36
+ assert payloads == [
37
+ {
38
+ "to": "Worker",
39
+ "text": "hello",
40
+ "from": "system",
41
+ "thread_id": "thread-1",
42
+ "priority": 5,
43
+ "data": {"k": "v"},
44
+ "mode": "steer",
45
+ }
46
+ ]
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_human_send_message_passes_mode_and_sets_message_mode():
51
+ relay = AgentRelay()
52
+ client = AsyncMock()
53
+ client.send_message = AsyncMock(return_value={"event_id": "evt-2"})
54
+ relay._ensure_started = AsyncMock(return_value=client)
55
+
56
+ human = HumanHandle("system", relay)
57
+ msg = await human.send_message(to="Worker", text="status?", mode="wait")
58
+
59
+ assert msg.mode == "wait"
60
+ client.send_message.assert_awaited_once_with(
61
+ to="Worker",
62
+ text="status?",
63
+ from_="system",
64
+ thread_id=None,
65
+ priority=None,
66
+ data=None,
67
+ mode="wait",
68
+ )
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_agent_send_message_passes_mode_and_sets_message_mode():
73
+ relay = AgentRelay()
74
+ client = AsyncMock()
75
+ client.spawn_pty = AsyncMock(return_value={"name": "Worker", "runtime": "pty"})
76
+ client.send_message = AsyncMock(return_value={"event_id": "evt-3"})
77
+ relay._ensure_started = AsyncMock(return_value=client)
78
+
79
+ agent = await relay.spawn("Worker", "claude")
80
+ msg = await agent.send_message(to="Reviewer", text="ready", mode="steer")
81
+
82
+ assert msg.mode == "steer"
83
+ client.send_message.assert_awaited_with(
84
+ to="Reviewer",
85
+ text="ready",
86
+ from_="Worker",
87
+ thread_id=None,
88
+ priority=None,
89
+ data=None,
90
+ mode="steer",
91
+ )
@@ -0,0 +1,323 @@
1
+ import Foundation
2
+
3
+ // MARK: - Delegate
4
+
5
+ @MainActor
6
+ public protocol RelayObserverDelegate: AnyObject {
7
+ func relayObserver(_ observer: RelayObserver, didReceiveEvent event: RelayObserverEvent)
8
+ func relayObserverDidConnect(_ observer: RelayObserver)
9
+ func relayObserverDidDisconnect(_ observer: RelayObserver, error: Error?)
10
+ }
11
+
12
+ // MARK: - RelayObserver
13
+
14
+ public final class RelayObserver: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
15
+
16
+ // MARK: - ConnectionState
17
+
18
+ public enum ConnectionState: Equatable, Sendable {
19
+ case disconnected
20
+ case connecting
21
+ case connected
22
+ case reconnecting(attempt: Int)
23
+ }
24
+
25
+ // MARK: - Public Properties (thread-safe via queue)
26
+
27
+ public var connectionState: ConnectionState {
28
+ queue.sync { _connectionState }
29
+ }
30
+ public var lastEvent: RelayObserverEvent? {
31
+ queue.sync { _lastEvent }
32
+ }
33
+ public var eventCounter: Int {
34
+ queue.sync { _eventCounter }
35
+ }
36
+ public weak var delegate: RelayObserverDelegate?
37
+
38
+ // MARK: - AsyncStream
39
+
40
+ public var events: AsyncStream<RelayObserverEvent> {
41
+ queue.sync {
42
+ _eventsContinuation?.finish()
43
+ return AsyncStream { continuation in
44
+ self._eventsContinuation = continuation
45
+ }
46
+ }
47
+ }
48
+
49
+ // MARK: - Private Properties
50
+
51
+ private let queue = DispatchQueue(label: "com.agentrelay.observer", qos: .userInitiated)
52
+ private var _connectionState: ConnectionState = .disconnected
53
+ private var _lastEvent: RelayObserverEvent?
54
+ private var _eventCounter: Int = 0
55
+ private var webSocketTask: URLSessionWebSocketTask?
56
+ private var urlSession: URLSession?
57
+ private let maxReconnectAttempts: Int
58
+ private let baseReconnectDelay: TimeInterval
59
+ private var reconnectAttempts: Int = 0
60
+ private var reconnectTask: Task<Void, Never>?
61
+ private var subscribedChannel: String?
62
+ private var pendingOutbound: [String] = []
63
+ private var isConnectionReady: Bool = false
64
+ private var activeURL: URL?
65
+ private var _eventsContinuation: AsyncStream<RelayObserverEvent>.Continuation?
66
+
67
+ private let jsonEncoder: JSONEncoder = {
68
+ let encoder = JSONEncoder()
69
+ encoder.keyEncodingStrategy = .convertToSnakeCase
70
+ return encoder
71
+ }()
72
+
73
+ private let jsonDecoder: JSONDecoder = {
74
+ // NOT using convertFromSnakeCase — we use explicit CodingKeys
75
+ // because of dual-name fields (name/agent_name, step/step_name)
76
+ let decoder = JSONDecoder()
77
+ return decoder
78
+ }()
79
+
80
+ // MARK: - Init
81
+
82
+ public init(maxReconnectAttempts: Int = 8, baseReconnectDelay: TimeInterval = 1.0) {
83
+ self.maxReconnectAttempts = maxReconnectAttempts
84
+ self.baseReconnectDelay = baseReconnectDelay
85
+ super.init()
86
+ }
87
+
88
+ // MARK: - Public Methods
89
+
90
+ /// Connect to a WebSocket proxy URL and subscribe to a channel on open.
91
+ public func connect(url: URL, channel: String) {
92
+ queue.sync {
93
+ self.subscribedChannel = channel
94
+ self._openSocket(url: url)
95
+ }
96
+ }
97
+
98
+ /// Connect to a WebSocket proxy URL without channel subscription.
99
+ public func connect(url: URL) {
100
+ queue.sync {
101
+ self.subscribedChannel = nil
102
+ self._openSocket(url: url)
103
+ }
104
+ }
105
+
106
+ /// Disconnect — closes socket, cancels reconnect, clears state.
107
+ public func disconnect() {
108
+ queue.sync {
109
+ reconnectTask?.cancel()
110
+ reconnectTask = nil
111
+ reconnectAttempts = 0
112
+ isConnectionReady = false
113
+ subscribedChannel = nil
114
+ pendingOutbound.removeAll()
115
+ activeURL = nil
116
+ _closeSocket(code: .goingAway, reason: nil)
117
+ _connectionState = .disconnected
118
+ _eventsContinuation?.finish()
119
+ _eventsContinuation = nil
120
+ }
121
+ }
122
+
123
+ /// Send a message to a channel through the proxy.
124
+ public func sendChannel(
125
+ channel: String,
126
+ text: String,
127
+ personas: [String]? = nil,
128
+ cliPreferences: [String: String]? = nil
129
+ ) throws {
130
+ let msg = ObserverChannelSendMessage(
131
+ channel: channel,
132
+ text: text,
133
+ personas: personas,
134
+ cliPreferences: cliPreferences
135
+ )
136
+ try queue.sync {
137
+ try _sendEncodable(msg)
138
+ }
139
+ }
140
+
141
+ /// Send a direct message to a specific agent through the proxy.
142
+ public func sendDirect(to: String, text: String) throws {
143
+ let msg = ObserverDirectSendMessage(to: to, text: text)
144
+ try queue.sync {
145
+ try _sendEncodable(msg)
146
+ }
147
+ }
148
+
149
+ // MARK: - Private Methods (must be called on queue)
150
+
151
+ private func _openSocket(url: URL) {
152
+ _closeSocket(code: .goingAway, reason: nil)
153
+ activeURL = url
154
+ _connectionState = .connecting
155
+ isConnectionReady = false
156
+
157
+ let delegateQueue = OperationQueue()
158
+ delegateQueue.underlyingQueue = queue
159
+ delegateQueue.maxConcurrentOperationCount = 1
160
+
161
+ let session = URLSession(
162
+ configuration: .default,
163
+ delegate: self,
164
+ delegateQueue: delegateQueue
165
+ )
166
+ self.urlSession = session
167
+ let task = session.webSocketTask(with: url)
168
+ self.webSocketTask = task
169
+ task.resume()
170
+ _scheduleReceive()
171
+ }
172
+
173
+ private func _closeSocket(code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
174
+ webSocketTask?.cancel(with: code, reason: reason)
175
+ webSocketTask = nil
176
+ urlSession?.invalidateAndCancel()
177
+ urlSession = nil
178
+ }
179
+
180
+ private func _scheduleReceive() {
181
+ webSocketTask?.receive { [weak self] result in
182
+ guard let self else { return }
183
+ // Callback arrives on our queue via delegateQueue
184
+ switch result {
185
+ case .success(let message):
186
+ self._handleMessage(message)
187
+ self._scheduleReceive()
188
+ case .failure(let error):
189
+ self._handleSocketError(error)
190
+ }
191
+ }
192
+ }
193
+
194
+ private func _handleMessage(_ message: URLSessionWebSocketTask.Message) {
195
+ let data: Data
196
+ switch message {
197
+ case .string(let text):
198
+ guard let d = text.data(using: .utf8) else { return }
199
+ data = d
200
+ case .data(let d):
201
+ data = d
202
+ @unknown default:
203
+ return
204
+ }
205
+
206
+ guard let event = try? jsonDecoder.decode(RelayObserverEvent.self, from: data) else { return }
207
+
208
+ _lastEvent = event
209
+ _eventCounter += 1
210
+
211
+ if let delegate {
212
+ Task { @MainActor in
213
+ delegate.relayObserver(self, didReceiveEvent: event)
214
+ }
215
+ }
216
+
217
+ _eventsContinuation?.yield(event)
218
+ }
219
+
220
+ private func _handleSocketError(_ error: Error) {
221
+ guard reconnectAttempts < maxReconnectAttempts else {
222
+ _connectionState = .disconnected
223
+ let delegate = self.delegate
224
+ Task { @MainActor in
225
+ delegate?.relayObserverDidDisconnect(self, error: error)
226
+ }
227
+ return
228
+ }
229
+
230
+ reconnectAttempts += 1
231
+ _connectionState = .reconnecting(attempt: reconnectAttempts)
232
+
233
+ let delay = baseReconnectDelay * pow(2.0, Double(reconnectAttempts - 1))
234
+
235
+ reconnectTask?.cancel()
236
+ reconnectTask = Task { [weak self] in
237
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
238
+ guard !Task.isCancelled, let self else { return }
239
+ self.queue.sync {
240
+ guard let url = self.activeURL else { return }
241
+ self._openSocket(url: url)
242
+ }
243
+ }
244
+ }
245
+
246
+ private func _sendSubscription() {
247
+ guard let channel = subscribedChannel else { return }
248
+ let msg = ObserverSubscribeMessage(channel: channel)
249
+ if let data = try? jsonEncoder.encode(msg),
250
+ let str = String(data: data, encoding: .utf8) {
251
+ webSocketTask?.send(.string(str)) { _ in }
252
+ }
253
+ }
254
+
255
+ private func _sendEncodable<T: Encodable>(_ value: T) throws {
256
+ guard let task = webSocketTask else {
257
+ throw RelayObserverError.notConnected
258
+ }
259
+ guard let data = try? jsonEncoder.encode(value),
260
+ let str = String(data: data, encoding: .utf8) else {
261
+ throw RelayObserverError.encodingFailed
262
+ }
263
+
264
+ if isConnectionReady {
265
+ task.send(.string(str)) { _ in }
266
+ } else {
267
+ pendingOutbound.append(str)
268
+ }
269
+ }
270
+
271
+ private func _flushPendingOutbound() {
272
+ guard let task = webSocketTask else { return }
273
+ for str in pendingOutbound {
274
+ task.send(.string(str)) { _ in }
275
+ }
276
+ pendingOutbound.removeAll()
277
+ }
278
+
279
+ // MARK: - URLSessionWebSocketDelegate (callbacks arrive on queue via delegateQueue)
280
+
281
+ public func urlSession(
282
+ _ session: URLSession,
283
+ webSocketTask: URLSessionWebSocketTask,
284
+ didOpenWithProtocol protocol: String?
285
+ ) {
286
+ _connectionState = .connected
287
+ reconnectAttempts = 0
288
+ isConnectionReady = true
289
+
290
+ _flushPendingOutbound()
291
+ _sendSubscription()
292
+
293
+ let delegate = self.delegate
294
+ Task { @MainActor in
295
+ delegate?.relayObserverDidConnect(self)
296
+ }
297
+ }
298
+
299
+ public func urlSession(
300
+ _ session: URLSession,
301
+ webSocketTask: URLSessionWebSocketTask,
302
+ didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
303
+ reason: Data?
304
+ ) {
305
+ isConnectionReady = false
306
+
307
+ if closeCode != .goingAway && closeCode != .normalClosure {
308
+ _handleSocketError(
309
+ NSError(
310
+ domain: "RelayObserver",
311
+ code: Int(closeCode.rawValue),
312
+ userInfo: [NSLocalizedDescriptionKey: "WebSocket closed with code \(closeCode.rawValue)"]
313
+ )
314
+ )
315
+ } else {
316
+ _connectionState = .disconnected
317
+ let delegate = self.delegate
318
+ Task { @MainActor in
319
+ delegate?.relayObserverDidDisconnect(self, error: nil)
320
+ }
321
+ }
322
+ }
323
+ }