From daed3414d526b59fabce268667a122923b884909 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 18:21:32 +0000 Subject: [PATCH 1/9] fix(sdk-swift): align RelayCast with v7 broker contract (#988) Drop the legacy hello/hello_ack WebSocket handshake and stop sending actions over /v1/ws. RelayCast now subscribes to /ws as a read-only event stream and uses the broker HTTP API (POST /api/spawn, DELETE /api/spawned/{name}, POST /api/send) for spawn, release, and message operations. --- CHANGELOG.md | 1 + .../Sources/AgentRelaySDK/RelayCast.swift | 220 +++++++++--------- .../Sources/AgentRelaySDK/RelayHTTP.swift | 102 ++++++++ .../AgentRelaySDK/RelayTransport.swift | 12 +- .../AgentRelaySDKTests.swift | 30 +++ 5 files changed, 244 insertions(+), 121 deletions(-) create mode 100644 packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d93eda36b..e11747f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `web`: PR preview SST deploys use and comment the generated CloudFront URL and AWS's managed disabled cache policy instead of creating per-preview Cloudflare DNS records, ACM certificates, and custom CloudFront cache policies. +- `sdk-swift`: `RelayCast` now connects to the v7 broker's `/ws` event stream without a legacy `hello`/`hello_ack` handshake and routes `spawnAgent`, `releaseAgent`, channel `post`, and agent `dm` through the broker's HTTP API (`/api/spawn`, `/api/spawned/{name}`, `/api/send`). ### Security diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift index f62f3c430..0fd57fbe2 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift @@ -52,21 +52,20 @@ public struct AgentRegistration: Sendable { actor RelayCore { let apiKey: String let transport: RelayTransport + let http: RelayHTTP let encoder = JSONEncoder() let decoder = JSONDecoder() - private var handshakeInFlight = false - private var handshakeGeneration = 0 - private var handshakeContinuations: [CheckedContinuation] = [] private var routerTask: Task? private var channelContinuations: [String: [AsyncStream.Continuation]] = [:] private var brokerEventContinuations: [AsyncStream.Continuation] = [] private var inboundMessageContinuations: [AsyncStream.Continuation] = [] private var connectionStateContinuations: [AsyncStream.Continuation] = [] - init(apiKey: String, transport: RelayTransport) { + init(apiKey: String, transport: RelayTransport, http: RelayHTTP) { self.apiKey = apiKey self.transport = transport + self.http = http } func configureTransportCallbacks() async { @@ -75,32 +74,21 @@ actor RelayCore { } } + /// Open the read-only event WebSocket if it's not already connected. + /// + /// v7 brokers expose `/ws` as a one-way broadcast — there is no + /// `hello`/`hello_ack` handshake, so a successful WebSocket upgrade is + /// the "connected" signal. func ensureConnected() async throws { if routerTask == nil || routerTask?.isCancelled == true { routerTask = Task { [weak self] in await self?.routeFrames() } } - if handshakeInFlight { - return try await waitForHandshake() - } - handshakeInFlight = true - handshakeGeneration &+= 1 try await transport.connect() - try await send(.hello(HelloPayload(clientName: "AgentRelaySDK.Swift", clientVersion: "0.1.0", apiKey: apiKey))) - try await waitForHandshake() + notifyConnectionState(.connected) } func transportDidConnect() async { - if handshakeInFlight { - finishHandshake(with: RelayError.connectionFailed("Transport reconnected before previous handshake completed")) - } - handshakeInFlight = true - handshakeGeneration &+= 1 notifyConnectionState(.connected) - do { - try await send(.hello(HelloPayload(clientName: "AgentRelaySDK.Swift", clientVersion: "0.1.0", apiKey: apiKey))) - } catch { - finishHandshake(with: error) - } } func registerChannelContinuation(_ continuation: AsyncStream.Continuation, for channel: String) { @@ -120,28 +108,33 @@ actor RelayCore { } func sendChannelPost(channel: String, text: String) async throws { - try await ensureConnected() - try await send(.sendMessage(SendMessagePayload(to: channel, text: text, from: nil, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil))) + try await sendMessageHTTP(SendMessagePayload(to: channel, text: text, from: nil, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil)) } func sendAgentMessage(from agentName: String, to target: String, text: String) async throws { - try await ensureConnected() - try await send(.sendMessage(SendMessagePayload(to: target, text: text, from: agentName, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil))) + try await sendMessageHTTP(SendMessagePayload(to: target, text: text, from: agentName, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil)) } func spawnAgent(_ spec: AgentSpec, initialTask: String? = nil, skipRelayPrompt: Bool? = nil) async throws { - try await ensureConnected() - try await send(.spawnAgent(SpawnAgentPayload(agent: spec, initialTask: initialTask, skipRelayPrompt: skipRelayPrompt))) + let body = try encodeJSON(SpawnRequestBody(spec: spec, task: initialTask, skipRelayPrompt: skipRelayPrompt)) + _ = try await http.post(path: "/api/spawn", body: body) } func releaseAgent(name: String, reason: String? = nil) async throws { - try await ensureConnected() - try await send(.releaseAgent(ReleaseAgentPayload(name: name, reason: reason))) + let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + let body: Data? + if let reason { + body = try encodeJSON(["reason": reason]) + } else { + body = nil + } + _ = try await http.delete(path: "/api/spawned/\(escaped)", body: body) } func registerOrRotate(name: String) async throws -> AgentRegistration { - try await ensureConnected() - return AgentRegistration(agentName: name, token: name) { agentName, token in + // v7 brokers do not have a register/rotate endpoint — agents are + // identified by name and authenticated via the broker API key. + AgentRegistration(agentName: name, token: name) { agentName, token in AgentClient(core: self, agentName: agentName, token: token) } } @@ -149,12 +142,6 @@ actor RelayCore { func disconnect() async { routerTask?.cancel() routerTask = nil - handshakeInFlight = false - let pendingHandshakes = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in pendingHandshakes { - continuation.resume(throwing: RelayError.notConnected) - } await transport.disconnect() notifyConnectionState(.disconnected) // Finish all event stream continuations @@ -170,55 +157,19 @@ actor RelayCore { connectionStateContinuations.removeAll() } - private func send(_ message: OutboundMessage) async throws { + private func sendMessageHTTP(_ payload: SendMessagePayload) async throws { + let body = try encodeJSON(payload) + _ = try await http.post(path: "/api/send", body: body) + } + + private func encodeJSON(_ value: T) throws -> Data { do { - let data = try encoder.encode(message) - try await transport.send(data) - } catch let error as RelayTransport.TransportError { - switch error { - case .notConnected: throw RelayError.notConnected - case .connectionFailed(let message), .sendFailed(let message): throw RelayError.connectionFailed(message) - case .invalidResponse: throw RelayError.connectionFailed("Invalid response") - } + return try encoder.encode(value) } catch { throw RelayError.encodingFailed(String(describing: error)) } } - private func waitForHandshake() async throws { - let generation = handshakeGeneration - try await withCheckedThrowingContinuation { continuation in - handshakeContinuations.append(continuation) - Task { [weak self] in - try? await Task.sleep(for: .seconds(10)) - await self?.failHandshakeIfPending(generation: generation, with: RelayError.timeout("Timed out waiting for hello_ack")) - } - } - } - - private func finishHandshake() { - handshakeInFlight = false - let continuations = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in continuations { - continuation.resume(returning: ()) - } - } - - private func finishHandshake(with error: Error) { - handshakeInFlight = false - let continuations = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in continuations { - continuation.resume(throwing: error) - } - } - - private func failHandshakeIfPending(generation: Int, with error: Error) { - guard handshakeInFlight, handshakeGeneration == generation else { return } - finishHandshake(with: error) - } - private func notifyConnectionState(_ state: ConnectionStateChange) { for continuation in connectionStateContinuations { continuation.yield(state) @@ -227,50 +178,86 @@ actor RelayCore { private func routeFrames() async { for await data in transport.inbound { - do { - let inbound = try decoder.decode(InboundMessage.self, from: data) + // v7 brokers send each event as a bare JSON object on the WS + // (`{kind: "...", ...}`) — there is no `{type, payload}` envelope. + // Decode as BrokerEvent directly and surface it on every stream. + guard let event = try? decoder.decode(BrokerEvent.self, from: data) else { + continue + } - // Notify all raw inbound message subscribers - for continuation in inboundMessageContinuations { - continuation.yield(inbound) - } + // Wrap in InboundMessage.event for the legacy raw-message stream. + for continuation in inboundMessageContinuations { + continuation.yield(.event(event)) + } + for continuation in brokerEventContinuations { + continuation.yield(event) + } - switch inbound { - case .helloAck: - finishHandshake() - case .event(let event): - // Notify all broker event subscribers - for continuation in brokerEventContinuations { - continuation.yield(event) - } - - // Route relay_inbound events to channel subscribers - if case .relayInbound(let relayEvent) = event { - let message = RelayChannelEvent(from: relayEvent.from, body: relayEvent.body, threadId: relayEvent.threadId) - for continuation in channelContinuations[relayEvent.target] ?? [] { - continuation.yield(message) - } - } - case .deliverRelay(let delivery): - // Route relay deliveries to channel subscribers as RelayChannelEvents - let message = RelayChannelEvent(from: delivery.from, body: delivery.body, threadId: delivery.threadId) - for continuation in channelContinuations[delivery.target] ?? [] { - continuation.yield(message) - } - case .error(let error): - finishHandshake(with: RelayError.protocolError(code: error.code, message: error.message, retryable: error.retryable)) - default: - break + if case .relayInbound(let relayEvent) = event { + let message = RelayChannelEvent(from: relayEvent.from, body: relayEvent.body, threadId: relayEvent.threadId) + for continuation in channelContinuations[relayEvent.target] ?? [] { + continuation.yield(message) } - } catch { - continue } } - // Transport stream ended (disconnection) notifyConnectionState(.disconnected) } } +/// Body shape for `POST /api/spawn` — flattens the AgentSpec fields onto the +/// request as the broker expects (name, cli, runtime, args, channels, model, +/// cwd, team, etc.) plus optional `task` and `skipRelayPrompt`. +private struct SpawnRequestBody: Encodable { + let spec: AgentSpec + let task: String? + let skipRelayPrompt: Bool? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: SpawnCodingKeys.self) + try container.encode(spec.name, forKey: .name) + try container.encode(spec.runtime.rawValue, forKey: .runtime) + if let provider = spec.provider { + try container.encode(provider.rawValue, forKey: .cli) + } else if let cli = spec.cli { + try container.encode(cli, forKey: .cli) + } + if let model = spec.model { + try container.encode(model, forKey: .model) + } + try container.encode(spec.args ?? [], forKey: .args) + try container.encode(spec.channels ?? [], forKey: .channels) + if let cwd = spec.cwd { + try container.encode(cwd, forKey: .cwd) + } + if let team = spec.team { + try container.encode(team, forKey: .team) + } + if let shadowOf = spec.shadowOf { + try container.encode(shadowOf, forKey: .shadowOf) + } + if let shadowMode = spec.shadowMode { + try container.encode(shadowMode, forKey: .shadowMode) + } + if let restartPolicy = spec.restartPolicy { + try container.encode(restartPolicy, forKey: .restartPolicy) + } + if let task { + try container.encode(task, forKey: .task) + } + if let skipRelayPrompt { + try container.encode(skipRelayPrompt, forKey: .skipRelayPrompt) + } + } + + enum SpawnCodingKeys: String, CodingKey { + case name, cli, runtime, model, args, channels, cwd, team, task + case shadowOf = "shadow_of" + case shadowMode = "shadow_mode" + case restartPolicy = "restart_policy" + case skipRelayPrompt = "skip_relay_prompt" + } +} + public final class RelayCast: @unchecked Sendable { private let core: RelayCore public let apiKey: String @@ -281,7 +268,8 @@ public final class RelayCast: @unchecked Sendable { let resolved = Self.resolveBaseURL(from: baseURL) self.baseURL = resolved let transport = RelayTransport(baseURL: resolved, authToken: apiKey) - self.core = RelayCore(apiKey: apiKey, transport: transport) + let http = RelayHTTP(baseURL: resolved, apiKey: apiKey) + self.core = RelayCore(apiKey: apiKey, transport: transport, http: http) Task { await self.core.configureTransportCallbacks() } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift new file mode 100644 index 000000000..85cdcfc15 --- /dev/null +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift @@ -0,0 +1,102 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Minimal HTTP client for the v7 broker REST API. +/// +/// In v7, agent lifecycle and messaging moved off the WebSocket onto plain +/// HTTP endpoints (`POST /api/spawn`, `DELETE /api/spawned/{name}`, +/// `POST /api/send`). The WebSocket at `/ws` is a read-only event broadcast. +actor RelayHTTP { + private let baseURL: URL + private let apiKey: String + private let session: URLSession + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + return encoder + }() + private let decoder: JSONDecoder = JSONDecoder() + + init(baseURL: URL, apiKey: String, session: URLSession = .shared) { + self.baseURL = baseURL + self.apiKey = apiKey + self.session = session + } + + func post(path: String, body: Data?) async throws -> Data { + try await request(method: "POST", path: path, body: body) + } + + func delete(path: String, body: Data?) async throws -> Data { + try await request(method: "DELETE", path: path, body: body) + } + + func get(path: String) async throws -> Data { + try await request(method: "GET", path: path, body: nil) + } + + private func request(method: String, path: String, body: Data?) async throws -> Data { + guard let url = url(for: path) else { + throw RelayError.invalidBaseURL("Could not resolve URL for path \(path)") + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw RelayError.connectionFailed(String(describing: error)) + } + + guard let http = response as? HTTPURLResponse else { + throw RelayError.connectionFailed("Non-HTTP response from broker") + } + + if !(200..<300).contains(http.statusCode) { + let (code, message) = decodeErrorBody(data, fallbackStatus: http.statusCode) + throw RelayError.protocolError( + code: code, + message: message, + retryable: http.statusCode >= 500 + ) + } + + return data + } + + private func url(for path: String) -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + if components.scheme == "ws" { components.scheme = "http" } + if components.scheme == "wss" { components.scheme = "https" } + + let trimmedBase = (components.path).hasSuffix("/") + ? String(components.path.dropLast()) + : components.path + let normalizedPath = path.hasPrefix("/") ? path : "/" + path + components.path = trimmedBase + normalizedPath + return components.url + } + + private func decodeErrorBody(_ data: Data, fallbackStatus: Int) -> (code: String, message: String) { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let code = (json["code"] as? String) ?? "http_\(fallbackStatus)" + let message = + (json["message"] as? String) + ?? (json["error"] as? String) + ?? "HTTP \(fallbackStatus)" + return (code, message) + } + return ("http_\(fallbackStatus)", "HTTP \(fallbackStatus)") + } +} diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift index 3fef03e41..d53aef2b7 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift @@ -99,15 +99,17 @@ public actor RelayTransport { } private func websocketRequest() -> URLRequest { + // v7 broker exposes the read-only event stream at `/ws` with `X-API-Key` + // auth — the legacy `/v1/ws` path and `token` query parameter are gone. var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) if components?.scheme == "http" { components?.scheme = "ws" } if components?.scheme == "https" { components?.scheme = "wss" } - if components?.path.isEmpty ?? true { components?.path = "/v1/ws" } - if !(components?.path.hasSuffix("/v1/ws") ?? false) && !(components?.path.hasSuffix("/ws") ?? false) { - components?.path = "/v1/ws" + let existingPath = components?.path ?? "" + if existingPath.isEmpty || existingPath == "/" { + components?.path = "/ws" + } else if !existingPath.hasSuffix("/ws") && !existingPath.hasSuffix("/v1/ws") { + components?.path = existingPath.hasSuffix("/") ? existingPath + "ws" : existingPath + "/ws" } - let existingItems = components?.queryItems ?? [] - components?.queryItems = existingItems + [URLQueryItem(name: "token", value: authToken)] var request = URLRequest(url: components?.url ?? baseURL) request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 12acc83bd..88e7d18a7 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -12,4 +12,34 @@ final class AgentRelaySDKTests: XCTestCase { let channel = relay.channel("test-channel") XCTAssertEqual(channel.name, "test-channel") } + + func testRelayCastUsesDefaultLocalBrokerURL() { + let relay = RelayCast(apiKey: "rk_test_key") + XCTAssertEqual(relay.baseURL.host, "localhost") + XCTAssertEqual(relay.baseURL.port, 3889) + } + + /// v7 broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. + /// Make sure the SDK can still decode the broker's wire format. + func testBrokerEventDecodesBarePayload() throws { + let json = """ + { + "kind": "relay_inbound", + "event_id": "evt_1", + "from": "alice", + "target": "wf-test", + "body": "hello", + "thread_id": "t1" + } + """.data(using: .utf8)! + let event = try JSONDecoder().decode(BrokerEvent.self, from: json) + if case .relayInbound(let inbound) = event { + XCTAssertEqual(inbound.from, "alice") + XCTAssertEqual(inbound.target, "wf-test") + XCTAssertEqual(inbound.body, "hello") + XCTAssertEqual(inbound.threadId, "t1") + } else { + XCTFail("expected relayInbound, got \(event)") + } + } } From bae112f7b7459d77b4527396d679de892c317180 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:53:58 +0000 Subject: [PATCH 2/9] refactor(sdk-swift): rename RelayCast to AgentRelayClient `RelayCast` conflated this broker client with the unrelated Relaycast cloud service (api.relaycast.dev). The class actually targets a local or remote agent-relay-broker over its /ws and /api/* endpoints, so rename it to `AgentRelayClient` (matching the Node SDK). `RelayCast` stays around as a `@available(*, deprecated, renamed:)` typealias so existing Swift consumers keep compiling. --- CHANGELOG.md | 3 ++- packages/sdk-swift/README.md | 18 ++++++++++--- ...RelayCast.swift => AgentRelayClient.swift} | 9 ++++++- .../AgentRelaySDKTests.swift | 26 ++++++++++++------- 4 files changed, 42 insertions(+), 14 deletions(-) rename packages/sdk-swift/Sources/AgentRelaySDK/{RelayCast.swift => AgentRelayClient.swift} (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e11747f16..a3369ba2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Release workflow changelog generation now writes concise Keep a Changelog sections and skips web-only, release-only, trajectory, PR-review, placeholder, and withdrawn-tag entries. +- `sdk-swift`: renamed the broker client class `RelayCast` → `AgentRelayClient` to stop conflating it with the unrelated Relaycast cloud service. `RelayCast` remains as a deprecated typealias. ### Fixed - `web`: PR preview SST deploys use and comment the generated CloudFront URL and AWS's managed disabled cache policy instead of creating per-preview Cloudflare DNS records, ACM certificates, and custom CloudFront cache policies. -- `sdk-swift`: `RelayCast` now connects to the v7 broker's `/ws` event stream without a legacy `hello`/`hello_ack` handshake and routes `spawnAgent`, `releaseAgent`, channel `post`, and agent `dm` through the broker's HTTP API (`/api/spawn`, `/api/spawned/{name}`, `/api/send`). +- `sdk-swift`: broker client now connects to the v7 broker's `/ws` event stream without a legacy `hello`/`hello_ack` handshake and routes `spawnAgent`, `releaseAgent`, channel `post`, and agent `dm` through the broker's HTTP API (`/api/spawn`, `/api/spawned/{name}`, `/api/send`). ### Security diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index 80726b631..ffb16fa8d 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -2,6 +2,10 @@ Native Swift SDK for the Agent Relay broker. +This SDK talks to a local or remote `agent-relay-broker` over its `/ws` event +stream and `/api/*` HTTP endpoints. It is **not** a client for the separate +Relaycast cloud service (`api.relaycast.dev`). + ## Installation Add the package in Swift Package Manager: @@ -19,8 +23,10 @@ Then depend on `AgentRelaySDK`. ```swift import AgentRelaySDK -let relay = RelayCast(apiKey: "rk_live_...") -let channel = relay.channel("wf-my-workflow") +// Point at a local broker started with `agent-relay up` (defaults to +// http://localhost:3889) or pass `baseURL:` for a remote broker. +let client = AgentRelayClient(apiKey: "rk_live_...") +let channel = client.channel("wf-my-workflow") try await channel.subscribe() try await channel.post("Hello from Swift") @@ -31,9 +37,15 @@ for await event in channel.events { ## API -- `RelayCast(apiKey:baseURL:)` +- `AgentRelayClient(apiKey:baseURL:)` — broker client - `channel(_:) -> Channel` +- `spawnAgent(_:initialTask:skipRelayPrompt:)` +- `releaseAgent(name:reason:)` - `registerOrRotate(name:)` - `AgentRegistration.asClient()` - `AgentClient.post(to:message:)` - `AgentClient.dm(to:message:)` + +> The previous class name `RelayCast` is preserved as a deprecated typealias +> for `AgentRelayClient`. It was renamed because it conflated this broker +> client with the unrelated Relaycast cloud service. diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift similarity index 96% rename from packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift rename to packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index 0fd57fbe2..9984bf30e 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -1,5 +1,12 @@ import Foundation +/// Deprecated name for ``AgentRelayClient``. The class used to be called +/// `RelayCast`, which conflated it with the separate Relaycast cloud service +/// (`api.relaycast.dev`). This Swift class actually talks to a local or +/// remote `agent-relay-broker` over its `/ws` and `/api/*` endpoints. +@available(*, deprecated, renamed: "AgentRelayClient", message: "RelayCast was misnamed — it is a broker client, not the Relaycast cloud client. Use AgentRelayClient.") +public typealias RelayCast = AgentRelayClient + public enum RelayError: Error, Sendable { case invalidBaseURL(String) case connectionFailed(String) @@ -258,7 +265,7 @@ private struct SpawnRequestBody: Encodable { } } -public final class RelayCast: @unchecked Sendable { +public final class AgentRelayClient: @unchecked Sendable { private let core: RelayCore public let apiKey: String public let baseURL: URL diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 88e7d18a7..cb6f0705f 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -2,21 +2,29 @@ import XCTest @testable import AgentRelaySDK final class AgentRelaySDKTests: XCTestCase { - func testRelayCastInit() { - let relay = RelayCast(apiKey: "rk_test_key") - XCTAssertEqual(relay.apiKey, "rk_test_key") + func testAgentRelayClientInit() { + let client = AgentRelayClient(apiKey: "rk_test_key") + XCTAssertEqual(client.apiKey, "rk_test_key") } func testChannelCreation() { - let relay = RelayCast(apiKey: "rk_test_key") - let channel = relay.channel("test-channel") + let client = AgentRelayClient(apiKey: "rk_test_key") + let channel = client.channel("test-channel") XCTAssertEqual(channel.name, "test-channel") } - func testRelayCastUsesDefaultLocalBrokerURL() { - let relay = RelayCast(apiKey: "rk_test_key") - XCTAssertEqual(relay.baseURL.host, "localhost") - XCTAssertEqual(relay.baseURL.port, 3889) + func testDefaultLocalBrokerURL() { + let client = AgentRelayClient(apiKey: "rk_test_key") + XCTAssertEqual(client.baseURL.host, "localhost") + XCTAssertEqual(client.baseURL.port, 3889) + } + + /// Old name still resolves to the same type — guards against accidentally + /// removing the back-compat typealias. + @available(*, deprecated) + func testRelayCastTypealiasResolves() { + let legacy: RelayCast = AgentRelayClient(apiKey: "rk_test_key") + XCTAssertEqual(legacy.apiKey, "rk_test_key") } /// v7 broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. From c591861577fc8a826a0ef5fa161db576a6a8130a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:00:27 +0000 Subject: [PATCH 3/9] refactor(sdk-swift): drop RelayCast deprecated typealias The rename to AgentRelayClient is a hard break; no back-compat shim. --- packages/sdk-swift/README.md | 4 ---- .../Sources/AgentRelaySDK/AgentRelayClient.swift | 7 ------- .../Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift | 8 -------- 3 files changed, 19 deletions(-) diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index ffb16fa8d..814354144 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -45,7 +45,3 @@ for await event in channel.events { - `AgentRegistration.asClient()` - `AgentClient.post(to:message:)` - `AgentClient.dm(to:message:)` - -> The previous class name `RelayCast` is preserved as a deprecated typealias -> for `AgentRelayClient`. It was renamed because it conflated this broker -> client with the unrelated Relaycast cloud service. diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index 9984bf30e..5055b6c98 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -1,12 +1,5 @@ import Foundation -/// Deprecated name for ``AgentRelayClient``. The class used to be called -/// `RelayCast`, which conflated it with the separate Relaycast cloud service -/// (`api.relaycast.dev`). This Swift class actually talks to a local or -/// remote `agent-relay-broker` over its `/ws` and `/api/*` endpoints. -@available(*, deprecated, renamed: "AgentRelayClient", message: "RelayCast was misnamed — it is a broker client, not the Relaycast cloud client. Use AgentRelayClient.") -public typealias RelayCast = AgentRelayClient - public enum RelayError: Error, Sendable { case invalidBaseURL(String) case connectionFailed(String) diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index cb6f0705f..43dc2a9a6 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -19,14 +19,6 @@ final class AgentRelaySDKTests: XCTestCase { XCTAssertEqual(client.baseURL.port, 3889) } - /// Old name still resolves to the same type — guards against accidentally - /// removing the back-compat typealias. - @available(*, deprecated) - func testRelayCastTypealiasResolves() { - let legacy: RelayCast = AgentRelayClient(apiKey: "rk_test_key") - XCTAssertEqual(legacy.apiKey, "rk_test_key") - } - /// v7 broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. /// Make sure the SDK can still decode the broker's wire format. func testBrokerEventDecodesBarePayload() throws { From d7da2a20ce72cc6323baae44cd660abccb15d9a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:03:57 +0000 Subject: [PATCH 4/9] docs(sdk-swift): drop comments narrating removed v6 behavior Comments that describe how things used to work (handshake gone, paths removed, envelopes replaced) just rot; the code already shows what it does. --- .../Sources/AgentRelaySDK/AgentRelayClient.swift | 14 -------------- .../Sources/AgentRelaySDK/RelayHTTP.swift | 6 +----- .../Sources/AgentRelaySDK/RelayTransport.swift | 2 -- .../AgentRelaySDKTests/AgentRelaySDKTests.swift | 3 +-- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index 5055b6c98..aff2bbc1e 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -75,10 +75,6 @@ actor RelayCore { } /// Open the read-only event WebSocket if it's not already connected. - /// - /// v7 brokers expose `/ws` as a one-way broadcast — there is no - /// `hello`/`hello_ack` handshake, so a successful WebSocket upgrade is - /// the "connected" signal. func ensureConnected() async throws { if routerTask == nil || routerTask?.isCancelled == true { routerTask = Task { [weak self] in await self?.routeFrames() } @@ -132,8 +128,6 @@ actor RelayCore { } func registerOrRotate(name: String) async throws -> AgentRegistration { - // v7 brokers do not have a register/rotate endpoint — agents are - // identified by name and authenticated via the broker API key. AgentRegistration(agentName: name, token: name) { agentName, token in AgentClient(core: self, agentName: agentName, token: token) } @@ -178,14 +172,10 @@ actor RelayCore { private func routeFrames() async { for await data in transport.inbound { - // v7 brokers send each event as a bare JSON object on the WS - // (`{kind: "...", ...}`) — there is no `{type, payload}` envelope. - // Decode as BrokerEvent directly and surface it on every stream. guard let event = try? decoder.decode(BrokerEvent.self, from: data) else { continue } - // Wrap in InboundMessage.event for the legacy raw-message stream. for continuation in inboundMessageContinuations { continuation.yield(.event(event)) } @@ -319,10 +309,6 @@ public final class AgentRelayClient: @unchecked Sendable { } /// Stream of all raw inbound protocol messages. - /// - /// This is the lowest-level event stream, including hello_ack, ok, error, - /// event, deliver_relay, worker_stream, worker_exited, and pong frames. - /// Use this when you need full protocol visibility. public var inboundMessages: AsyncStream { AsyncStream { continuation in Task { await core.registerInboundMessageContinuation(continuation) } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift index 85cdcfc15..3aa68c44e 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift @@ -4,11 +4,7 @@ import Foundation import FoundationNetworking #endif -/// Minimal HTTP client for the v7 broker REST API. -/// -/// In v7, agent lifecycle and messaging moved off the WebSocket onto plain -/// HTTP endpoints (`POST /api/spawn`, `DELETE /api/spawned/{name}`, -/// `POST /api/send`). The WebSocket at `/ws` is a read-only event broadcast. +/// Minimal HTTP client for the broker REST API. actor RelayHTTP { private let baseURL: URL private let apiKey: String diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift index d53aef2b7..ceec3fe18 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift @@ -99,8 +99,6 @@ public actor RelayTransport { } private func websocketRequest() -> URLRequest { - // v7 broker exposes the read-only event stream at `/ws` with `X-API-Key` - // auth — the legacy `/v1/ws` path and `token` query parameter are gone. var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) if components?.scheme == "http" { components?.scheme = "ws" } if components?.scheme == "https" { components?.scheme = "wss" } diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 43dc2a9a6..17b8cab7a 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -19,8 +19,7 @@ final class AgentRelaySDKTests: XCTestCase { XCTAssertEqual(client.baseURL.port, 3889) } - /// v7 broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. - /// Make sure the SDK can still decode the broker's wire format. + /// The broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. func testBrokerEventDecodesBarePayload() throws { let json = """ { From 488dbe97c6e023a3cb225fc4960a0893198df1a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:12:05 +0000 Subject: [PATCH 5/9] ci: skip Node/Rust jobs when only sdk-swift changes Extends the existing web-only skip pattern in test.yml, rust-ci.yml, package-validation.yml, and node-compat.yml with a parallel sdk_swift_only flag. Also negates packages/sdk-swift/** in e2e-tests.yml and relay-cleanroom-hardening.yml's packages/** path filter. Swift-only PRs currently have no Swift CI to run; tracked separately for a path-area refactor that adds it. --- .github/workflows/e2e-tests.yml | 2 ++ .github/workflows/node-compat.yml | 9 ++++++--- .github/workflows/package-validation.yml | 11 +++++++---- .github/workflows/relay-cleanroom-hardening.yml | 1 + .github/workflows/rust-ci.yml | 15 +++++++++------ .github/workflows/test.yml | 13 ++++++++----- 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index fc2f39f40..fada10c53 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -6,6 +6,7 @@ on: paths: - 'src/**' - 'packages/**' + - '!packages/sdk-swift/**' - 'scripts/e2e-test.sh' - 'scripts/e2e-sdk-lifecycle.mjs' - 'package.json' @@ -15,6 +16,7 @@ on: paths: - 'src/**' - 'packages/**' + - '!packages/sdk-swift/**' - 'scripts/e2e-test.sh' - 'scripts/e2e-sdk-lifecycle.mjs' - 'package.json' diff --git a/.github/workflows/node-compat.yml b/.github/workflows/node-compat.yml index f04a93858..a34c86fb2 100644 --- a/.github/workflows/node-compat.yml +++ b/.github/workflows/node-compat.yml @@ -12,10 +12,11 @@ concurrency: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -42,13 +43,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); install-test: name: Install Test (Node ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest strategy: matrix: @@ -104,7 +107,7 @@ jobs: fresh-install: name: Fresh Install (Node ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index ea1b18a6c..43d45aab0 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -15,10 +15,11 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -45,13 +46,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); validate: name: Build & Validate needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest env: NPM_CONFIG_FUND: false @@ -228,7 +231,7 @@ jobs: publish-fresh-install-build: name: Publish Fresh Install Build needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest env: NPM_CONFIG_FUND: false @@ -262,7 +265,7 @@ jobs: standalone-macos-smoke: name: Standalone macOS Smoke needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: macos-latest env: NPM_CONFIG_FUND: false diff --git a/.github/workflows/relay-cleanroom-hardening.yml b/.github/workflows/relay-cleanroom-hardening.yml index e76823b3a..e9a375761 100644 --- a/.github/workflows/relay-cleanroom-hardening.yml +++ b/.github/workflows/relay-cleanroom-hardening.yml @@ -7,6 +7,7 @@ on: - 'install.sh' - 'src/**' - 'packages/**' + - '!packages/sdk-swift/**' - 'workflows/relay-e2e-meta-workflow.ts' - 'workflows/relay-clean-room-e2e-validation.ts' - 'scripts/run-relay-cleanroom-ci.sh' diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 5903d40e9..fa6ee9dd5 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -11,10 +11,11 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -41,13 +42,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); rust-test: name: Rust Tests (${{ matrix.os }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -64,7 +67,7 @@ jobs: rust-cross-compile: name: Cross-compile check (${{ matrix.target }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -98,7 +101,7 @@ jobs: rust-clippy: name: Clippy needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -113,7 +116,7 @@ jobs: rust-fmt: name: Format needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -125,7 +128,7 @@ jobs: sdk-check: name: SDK TypeScript Check needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 544c779e0..445583807 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,11 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -45,12 +46,14 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); test: needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: matrix: @@ -80,7 +83,7 @@ jobs: coverage: name: Coverage (upload) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -108,7 +111,7 @@ jobs: lint: needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -135,7 +138,7 @@ jobs: rust-test: name: Rust Tests (agent-relay-broker) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: matrix: From bc1e02a7b9aad15ba6939c8c0173dda934563e75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:15:42 +0000 Subject: [PATCH 6/9] fix(sdk-swift): correct URL building and reconnect on register Addresses PR review feedback: - RelayHTTP.url(for:) now strips a trailing /ws or /v1/ws from the base path so callers that pass a ws-shaped baseURL still hit /api/* instead of /ws/api/*. - RelayTransport.websocketRequest() normalizes trailing slashes before checking the /ws suffix (so /ws/ no longer becomes /ws/ws), rewrites legacy /v1/ws to /ws, and strips a legacy ?token= query. - releaseAgent uses a path-segment-safe CharacterSet that excludes /, so agent names containing slashes don't split the request path. - registerOrRotate calls ensureConnected() again, restoring the pre-PR behavior of opening the WS as part of registration. Extracted the URL normalization into static helpers and added unit tests covering each edge case. --- .../AgentRelaySDK/AgentRelayClient.swift | 7 ++- .../Sources/AgentRelaySDK/RelayHTTP.swift | 19 ++++-- .../AgentRelaySDK/RelayTransport.swift | 30 +++++++--- .../AgentRelaySDKTests.swift | 59 +++++++++++++++++++ 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index aff2bbc1e..6756b0849 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -117,7 +117,9 @@ actor RelayCore { } func releaseAgent(name: String, reason: String? = nil) async throws { - let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + var pathSegmentAllowed = CharacterSet.urlPathAllowed + pathSegmentAllowed.remove(charactersIn: "/") + let escaped = name.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? name let body: Data? if let reason { body = try encodeJSON(["reason": reason]) @@ -128,7 +130,8 @@ actor RelayCore { } func registerOrRotate(name: String) async throws -> AgentRegistration { - AgentRegistration(agentName: name, token: name) { agentName, token in + try await ensureConnected() + return AgentRegistration(agentName: name, token: name) { agentName, token in AgentClient(core: self, agentName: agentName, token: token) } } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift index 3aa68c44e..af04dc808 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift @@ -70,17 +70,28 @@ actor RelayHTTP { } private func url(for path: String) -> URL? { + Self.resolveAPIURL(baseURL: baseURL, path: path) + } + + static func resolveAPIURL(baseURL: URL, path: String) -> URL? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return nil } if components.scheme == "ws" { components.scheme = "http" } if components.scheme == "wss" { components.scheme = "https" } - let trimmedBase = (components.path).hasSuffix("/") - ? String(components.path.dropLast()) - : components.path + var basePath = components.path + while basePath.hasSuffix("/") { basePath = String(basePath.dropLast()) } + if basePath.hasSuffix("/v1/ws") { + basePath = String(basePath.dropLast("/v1/ws".count)) + } else if basePath.hasSuffix("/ws") { + basePath = String(basePath.dropLast("/ws".count)) + } + let normalizedPath = path.hasPrefix("/") ? path : "/" + path - components.path = trimmedBase + normalizedPath + components.path = basePath + normalizedPath + components.query = nil + components.fragment = nil return components.url } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift index ceec3fe18..b6ba6b09f 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift @@ -99,20 +99,32 @@ public actor RelayTransport { } private func websocketRequest() -> URLRequest { + var request = URLRequest(url: Self.resolveWebSocketURL(baseURL: baseURL) ?? baseURL) + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue(authToken, forHTTPHeaderField: "X-API-Key") + return request + } + + static func resolveWebSocketURL(baseURL: URL) -> URL? { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) if components?.scheme == "http" { components?.scheme = "ws" } if components?.scheme == "https" { components?.scheme = "wss" } - let existingPath = components?.path ?? "" - if existingPath.isEmpty || existingPath == "/" { - components?.path = "/ws" - } else if !existingPath.hasSuffix("/ws") && !existingPath.hasSuffix("/v1/ws") { - components?.path = existingPath.hasSuffix("/") ? existingPath + "ws" : existingPath + "/ws" + + var path = components?.path ?? "" + while path.hasSuffix("/") { path = String(path.dropLast()) } + if path.hasSuffix("/v1/ws") { + path = String(path.dropLast("/v1/ws".count)) + } else if path.hasSuffix("/ws") { + path = String(path.dropLast("/ws".count)) } + components?.path = path + "/ws" - var request = URLRequest(url: components?.url ?? baseURL) - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue(authToken, forHTTPHeaderField: "X-API-Key") - return request + components?.queryItems = components?.queryItems?.filter { $0.name != "token" } + if components?.queryItems?.isEmpty == true { + components?.queryItems = nil + } + + return components?.url } private func startReceiveLoop() { diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 17b8cab7a..662e4c960 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -41,4 +41,63 @@ final class AgentRelaySDKTests: XCTestCase { XCTFail("expected relayInbound, got \(event)") } } + + // MARK: - WebSocket URL resolution + + func testWebSocketURLAppendsWS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLUpgradesHTTPS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "https://broker.example.com")!) + XCTAssertEqual(url?.absoluteString, "wss://broker.example.com/ws") + } + + func testWebSocketURLNormalizesTrailingSlash() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/ws/")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLDoesNotDoubleAppendWS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/ws")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLRewritesLegacyV1WS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/v1/ws")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLStripsLegacyTokenQuery() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/?token=secret")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + // MARK: - REST API URL resolution + + func testAPIURLAppendsPath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLStripsWSBasePath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLStripsLegacyV1WSBasePath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889/v1/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLDowngradesWSScheme() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "ws://localhost:3889/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLDowngradesWSSScheme() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "wss://broker.example.com/ws")!, path: "/api/spawn") + XCTAssertEqual(url?.absoluteString, "https://broker.example.com/api/spawn") + } } From f014548ee94359f129e11c74870c267ef465e92a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 01:33:09 +0000 Subject: [PATCH 7/9] docs(sdk-swift): drop 'not the cloud service' framing --- CHANGELOG.md | 2 +- packages/sdk-swift/README.md | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff89f866b..e3a130c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@agent-relay/sdk` swaps `@agentworkforce/harness-kit` + `@agentworkforce/workload-router` for `@agentworkforce/persona-kit@^3`. The persona tier system, the `tier` option on `spawnPersona`, the legacy relay-side `PersonaFile` / `PersonaTier` / `PersonaTierSpec` / `ResolvedPersona` / `PersonaSpawnSpec` / `MaterializedConfigFile` types, and the `buildPersonaSpawnSpec` / `materializePersonaConfigFiles` / `restorePersonaConfigFiles` helpers are removed. `loadPersona` now returns the canonical `PersonaSpec`, and `spawnPersona({ persona })` takes a `PersonaSpec` instead of a resolved persona. - `agent-relay-broker`'s public Rust protocol types now require typed ID newtypes (`WorkerName`, `DeliveryId`, `EventId`, `WorkspaceId`, `WorkspaceAlias`, `ThreadId`, `AgentId`, `RequestId`, `ChannelName`, `MessageTarget`) on every protocol struct and enum variant in `protocol.rs`, `types.rs`, and `listen_api.rs::ListenApiRequest`. The new wrappers live in `crates/broker/src/lib.rs` under `pub mod ids`. JSON wire format is unchanged because every wrapper is `#[serde(transparent)]`, so the broker ↔ SDK channel and on-disk persisted state remain byte-compatible. - `agent-relay spawn` and SDK spawn calls now return harness `sessionId` metadata for resumable Claude and Codex PTY sessions. -- `sdk-swift`: renamed the broker client class `RelayCast` → `AgentRelayClient` to stop conflating it with the unrelated Relaycast cloud service. +- `sdk-swift`: renamed the broker client class `RelayCast` → `AgentRelayClient`. ### Migration Guidance diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index 814354144..c005cb3db 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -1,10 +1,7 @@ # AgentRelaySDK -Native Swift SDK for the Agent Relay broker. - -This SDK talks to a local or remote `agent-relay-broker` over its `/ws` event -stream and `/api/*` HTTP endpoints. It is **not** a client for the separate -Relaycast cloud service (`api.relaycast.dev`). +Native Swift SDK for `agent-relay-broker`. Talks to the broker over its `/ws` +event stream and `/api/*` HTTP endpoints. ## Installation From 5f9d558ae8cb9dee0a51c6710142640407910dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 01:42:50 +0000 Subject: [PATCH 8/9] ci: add Swift SDK test job on macOS Builds and runs swift test in packages/sdk-swift whenever any file in that directory changes. macOS runners are billed at 10x but the job is small and only fires for sdk-swift PRs. --- .github/workflows/test.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 445583807..936636959 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,7 @@ jobs: outputs: web_only: ${{ steps.scope.outputs.web_only }} sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} + sdk_swift_changed: ${{ steps.scope.outputs.sdk_swift_changed }} steps: - id: scope uses: actions/github-script@v7 @@ -47,9 +48,11 @@ jobs: const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); + const sdkSwiftChanged = files.some((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_changed', sdkSwiftChanged ? 'true' : 'false'); test: needs: changes @@ -165,3 +168,24 @@ jobs: - name: Run Clippy lints run: cargo clippy -- -D warnings + + swift-test: + name: Swift SDK Tests + needs: changes + if: needs.changes.outputs.sdk_swift_changed == 'true' + runs-on: macos-latest + defaults: + run: + working-directory: packages/sdk-swift + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show Swift version + run: swift --version + + - name: Build + run: swift build + + - name: Test + run: swift test From b3b5772fcfa5ccb81a416000d64cf89e5ca32940 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 02:58:21 +0000 Subject: [PATCH 9/9] fix(sdk-swift): avoid overlapping access on URLComponents query filter --- .../sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift index b6ba6b09f..32b1837bd 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift @@ -119,10 +119,8 @@ public actor RelayTransport { } components?.path = path + "/ws" - components?.queryItems = components?.queryItems?.filter { $0.name != "token" } - if components?.queryItems?.isEmpty == true { - components?.queryItems = nil - } + let filteredQuery = components?.queryItems?.filter { $0.name != "token" } + components?.queryItems = (filteredQuery?.isEmpty == false) ? filteredQuery : nil return components?.url }