From 9535b3c22575ead5711ee5c14df74597a66c7f07 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 22 Jun 2026 23:09:14 -0400 Subject: [PATCH 1/7] refactor(sdk-swift): wrap relaycast engine SDK in hosted transport Reduce AgentRelaySDK's hosted-participant transport to a thin facade over the published relaycast Swift engine SDK (product `Relaycast`, package `relaycast-swift`) instead of hand-rolling URLSession/WebSocket networking. - Replace the bespoke `HostedHTTP` (RelayHTTP.swift) and `RelayEventTransport` (RelayTransport.swift) with delegation to `Relaycast.RelayCast` / `Relaycast.AgentClient` / `Relaycast.WsClient`: - register / registerOrRotate -> RelayCast.agents.register / registerOrRotate - reconnect(apiToken:) -> RelayCast.reconnect + AgentClient.me - workspaceInfo -> RelayCast.workspace.info - post / dm -> AgentClient.send / dm - actions register/delete -> RelayCast.actions; invocation get/complete -> AgentClient.actions - realtime events -> AgentClient.on typed handlers (message.created, thread.reply, dm.received, group_dm.received, action.invoked) - Keep the relay-specific glue on top: the action-dispatch loop (action.invoked -> loadInvocation -> handler -> completeInvocation), the `RelayChannelEvent` shape, the AsyncStream-based public API, and every public type/signature of AgentRelayClient so callers don't break. - Preserve the default host `https://gateway.relaycast.dev`, passed explicitly into `RelayCastOptions.baseURL`. - Add `RelaycastBridge.swift` mapping relaycast types onto AgentRelaySDK's public surface (agent type/status, JSONValue, RelayError, and RelayEvent built from `Relaycast.WsEvent`). - Wire the SwiftPM dependency. relaycast-swift is not at its repo root and its directory basename (`sdk-swift`) collides with this package's identity, so the dependency is referenced through a committed symlink (`.relaycast-swift` -> sibling relaycast checkout); documented how to repoint it or swap in a git/tag reference once a root-level mirror is published. - Rewrite the hosted tests to cover the facade config and bridging glue; AgentRelayBrokerSDK and the RelayObserver code/tests are left untouched. RegisterActionRequest in relaycast already carries handler_agent and input_schema; no upstream change was required. Verified: `swift build` and `swift test` pass (69 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/.relaycast-swift | 1 + packages/sdk-swift/Package.swift | 35 ++ packages/sdk-swift/README.md | 30 ++ .../AgentRelaySDK/AgentRelayClient.swift | 472 ++++++++--------- .../Sources/AgentRelaySDK/RelayHTTP.swift | 142 ----- .../AgentRelaySDK/RelayTransport.swift | 214 -------- .../Sources/AgentRelaySDK/RelayTypes.swift | 82 +-- .../AgentRelaySDK/RelaycastBridge.swift | 96 ++++ .../AgentRelaySDKTests.swift | 500 ++++-------------- 9 files changed, 501 insertions(+), 1071 deletions(-) create mode 120000 packages/sdk-swift/.relaycast-swift delete mode 100644 packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift delete mode 100644 packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift create mode 100644 packages/sdk-swift/Sources/AgentRelaySDK/RelaycastBridge.swift diff --git a/packages/sdk-swift/.relaycast-swift b/packages/sdk-swift/.relaycast-swift new file mode 120000 index 000000000..151485d7c --- /dev/null +++ b/packages/sdk-swift/.relaycast-swift @@ -0,0 +1 @@ +../../../relaycast/packages/sdk-swift \ No newline at end of file diff --git a/packages/sdk-swift/Package.swift b/packages/sdk-swift/Package.swift index 0bc2547e5..15c8086ec 100644 --- a/packages/sdk-swift/Package.swift +++ b/packages/sdk-swift/Package.swift @@ -1,6 +1,35 @@ // swift-tools-version: 5.9 import PackageDescription +import Foundation +// The hosted-participant transport (`AgentRelaySDK`) is a thin facade over the +// published relaycast Swift engine SDK (product `Relaycast`, package +// `relaycast-swift`). +// +// relaycast-swift lives in a subdirectory of the relaycast monorepo +// (`packages/sdk-swift`), so SwiftPM cannot consume it directly via a git URL +// (git dependencies require `Package.swift` at the repository root). Until a +// root-level mirror/tag is published, this package references relaycast-swift by +// a local checkout. To build against a published version instead, replace the +// `.package(...)` entry below with a git/registry reference whose root is the +// relaycast Swift package, e.g.: +// +// .package(url: "https://github.com/AgentWorkforce/relaycast-swift.git", from: "4.1.6") +// +// (matching one of the relaycast monorepo tags, e.g. v4.1.6). +// +// Local-checkout details: +// * The relaycast Swift package directory is named `sdk-swift` — the same +// basename as THIS package. SwiftPM derives a path dependency's identity +// from that basename, so referencing it directly collides with this +// package's own identity. To avoid the collision we depend on the relaycast +// package through the uniquely named symlink committed alongside this +// manifest: `.relaycast-swift`. It points (relative) at a sibling clone of +// the relaycast monorepo: +// .relaycast-swift -> ../../../relaycast/packages/sdk-swift +// * To point at a checkout in a different location, repoint that symlink, e.g. +// ln -sfn /path/to/relaycast/packages/sdk-swift \ +// packages/sdk-swift/.relaycast-swift let package = Package( name: "AgentRelaySDK", platforms: [ @@ -13,9 +42,15 @@ let package = Package( .library(name: "AgentRelaySDK", targets: ["AgentRelaySDK"]), .library(name: "AgentRelayBrokerSDK", targets: ["AgentRelayBrokerSDK"]) ], + dependencies: [ + .package(name: "relaycast-swift", path: ".relaycast-swift") + ], targets: [ .target( name: "AgentRelaySDK", + dependencies: [ + .product(name: "Relaycast", package: "relaycast-swift") + ], path: "Sources/AgentRelaySDK" ), .target( diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index b3915378b..cc6a189a2 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -21,6 +21,36 @@ Add the package in Swift Package Manager: Then depend on either `AgentRelaySDK` or `AgentRelayBrokerSDK`. +### `AgentRelaySDK` wraps the relaycast engine SDK + +`AgentRelaySDK`'s hosted transport is a thin facade over the published relaycast +Swift engine SDK (product `Relaycast`, package `relaycast-swift`, which lives in +the relaycast monorepo under `packages/sdk-swift`). All HTTP and realtime +WebSocket work is delegated to relaycast; `AgentRelaySDK` keeps only the +relay-specific glue (action-dispatch loop, `RelayChannelEvent` shape, and the +`AsyncStream`-based public API). + +Because relaycast-swift is not at its repository root, SwiftPM cannot consume it +by git URL directly, and its directory basename (`sdk-swift`) collides with this +package's own identity. The dependency is therefore wired through a committed +symlink (`packages/sdk-swift/.relaycast-swift`) that points at a sibling clone of +the relaycast monorepo: + +``` +.relaycast-swift -> ../../../relaycast/packages/sdk-swift +``` + +To use a relaycast checkout in a different location, repoint that symlink: + +```sh +ln -sfn /path/to/relaycast/packages/sdk-swift packages/sdk-swift/.relaycast-swift +``` + +Once a root-level mirror/tag of relaycast-swift is published, replace the path +dependency in `Package.swift` with a git/registry reference, e.g. +`.package(url: "https://github.com/AgentWorkforce/relaycast-swift.git", from: "4.1.6")` +(matching a relaycast monorepo tag such as `v4.1.6`). + ## Quick start ```swift diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index da0e19a74..2d01d0fde 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -1,5 +1,13 @@ import Foundation - +import Relaycast + +/// Hosted-participant client. This is a thin facade over the relaycast Swift +/// engine SDK (`Relaycast`): registration, reconnect, workspace lookup, channel +/// posting, DMs, action handling, and the realtime event stream are all served +/// by `Relaycast.RelayCast` / `Relaycast.AgentClient` / `Relaycast.WsClient`. +/// +/// The public surface (types, method signatures, AsyncStream APIs) is preserved +/// so existing callers keep working; only the transport implementation changed. public final class AgentRelay: @unchecked Sendable { private let core: HostedWorkspaceCore public let workspaceKey: String @@ -9,8 +17,7 @@ public final class AgentRelay: @unchecked Sendable { self.workspaceKey = workspaceKey let resolved = Self.resolveBaseURL(from: baseURL) self.baseURL = resolved - let http = HostedHTTP(baseURL: resolved, apiKey: workspaceKey) - self.core = HostedWorkspaceCore(workspaceKey: workspaceKey, baseURL: resolved, http: http) + self.core = HostedWorkspaceCore(workspaceKey: workspaceKey, baseURL: resolved) } public convenience init(apiKey: String, baseURL: URL? = nil) { @@ -62,143 +69,107 @@ public typealias AgentRelayClient = AgentRelay final class HostedWorkspaceCore: @unchecked Sendable { let workspaceKey: String let baseURL: URL - let http: any HostedHTTPClient - let encoder = JSONEncoder() + let relay: Relaycast.RelayCast - init(workspaceKey: String, baseURL: URL, http: any HostedHTTPClient) { + init(workspaceKey: String, baseURL: URL) { self.workspaceKey = workspaceKey self.baseURL = baseURL - self.http = http + // PRESERVE the configured host: pass it explicitly into relaycast. + self.relay = (try? Relaycast.RelayCast( + options: Relaycast.RelayCastOptions( + apiKey: workspaceKey, + baseURL: baseURL.absoluteString + ) + ))! } func register(name: String, type: RelayAgentType) async throws -> AgentRegistration { - let body = try encode(RegisterAgentRequest(name: name, type: type)) - let response = try await http.post(path: "/v1/agents", body: body) - let registration = try decodeAPIData(response, as: AgentRegistrationResponse.self) - return makeRegistration(registration) + do { + let created = try await relay.agents.register( + Relaycast.CreateAgentRequest(name: name, type: type.relaycastType) + ) + return makeRegistration(created) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } func registerOrRotate(name: String, type: RelayAgentType) async throws -> AgentRegistration { do { - return try await register(name: name, type: type) - } catch RelayError.protocolError(code: let code, message: let message, retryable: _) where isNameConflict(code: code, message: message) { - let agent = try await getAgent(name: name) - let token = try await rotateToken(name: agent.name) - return AgentRegistration( - id: agent.id, - name: agent.name, - token: token, - status: agent.status, - createdAt: agent.createdAt ?? agent.lastSeenAt - ) { [baseURL, http] id, agentName, token in - let agentHTTP = HostedHTTP(baseURL: baseURL, apiKey: token) - let transport = RelayEventTransport(baseURL: baseURL, token: token) - let core = HostedParticipantCore( - agentId: id, - agentName: agentName, - token: token, - baseURL: baseURL, - workspaceHTTP: http, - agentHTTP: agentHTTP, - transport: transport - ) - return AgentClient(core: core, id: id, name: agentName, token: token) - } + let created = try await relay.registerOrRotate( + Relaycast.RegisterAgentRequest(name: name, type: type.relaycastType) + ) + return makeRegistration(created) + } catch let error as Relaycast.RelayError { + throw RelayError(error) } } func reconnect(apiToken: String) async throws -> AgentClient { - let agentHTTP = HostedHTTP(baseURL: baseURL, apiKey: apiToken) - let data = try await agentHTTP.get(path: "/v1/agent", query: nil) - let agent = try decodeAPIData(data, as: RelayAgent.self) - return agentClient(id: agent.id, name: agent.name, token: apiToken) + do { + let engine = try await relay.reconnect(Relaycast.AgentReconnectOptions(apiToken: apiToken)) + let me = try await engine.me() + let core = HostedParticipantCore(engine: engine, relay: relay, agentId: me.id, agentName: me.name, token: apiToken, baseURL: baseURL) + return AgentClient(core: core, id: me.id, name: me.name, token: apiToken) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } func agentClient(id: String, name: String, token: String) -> AgentClient { - let agentHTTP = HostedHTTP(baseURL: baseURL, apiKey: token) - let transport = RelayEventTransport(baseURL: baseURL, token: token) - let core = HostedParticipantCore( - agentId: id, - agentName: name, - token: token, - baseURL: baseURL, - workspaceHTTP: http, - agentHTTP: agentHTTP, - transport: transport - ) + let core = makeParticipantCore(id: id, name: name, token: token) return AgentClient(core: core, id: id, name: name, token: token) } func workspaceInfo() async throws -> JSONValue { - let data = try await http.get(path: "/v1/workspace", query: nil) - return try decodeAPIData(data, as: JSONValue.self) - } - - private func getAgent(name: String) async throws -> RelayAgent { - let data = try await http.get(path: "/v1/agents/\(Self.escapePath(name))", query: nil) - return try decodeAPIData(data, as: RelayAgent.self) + do { + let workspace = try await relay.workspace.info() + return Self.workspaceJSON(workspace) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } - private func rotateToken(name: String) async throws -> String { - let data = try await http.post(path: "/v1/agents/\(Self.escapePath(name))/rotate-token", body: encodeEmptyObject()) - return try decodeAPIData(data, as: RotateTokenResponse.self).token + func makeParticipantCore(id: String, name: String, token: String) -> HostedParticipantCore { + let engine = (try? relay.asAgent(token))! + return HostedParticipantCore(engine: engine, relay: relay, agentId: id, agentName: name, token: token, baseURL: baseURL) } - private func makeRegistration(_ response: AgentRegistrationResponse) -> AgentRegistration { + private func makeRegistration(_ response: Relaycast.CreateAgentResponse) -> AgentRegistration { AgentRegistration( id: response.id, name: response.name, token: response.token, - status: response.status, + status: RelayAgentStatus(response.status), createdAt: response.createdAt - ) { [baseURL, http] id, agentName, token in - let agentHTTP = HostedHTTP(baseURL: baseURL, apiKey: token) - let transport = RelayEventTransport(baseURL: baseURL, token: token) - let core = HostedParticipantCore( - agentId: id, - agentName: agentName, - token: token, - baseURL: baseURL, - workspaceHTTP: http, - agentHTTP: agentHTTP, - transport: transport - ) + ) { [weak self] id, agentName, token in + guard let self else { + fatalError("HostedWorkspaceCore deallocated before AgentRegistration.asClient()") + } + let core = self.makeParticipantCore(id: id, name: agentName, token: token) return AgentClient(core: core, id: id, name: agentName, token: token) } } - private func encode(_ value: T) throws -> Data { - do { - return try encoder.encode(value) - } catch { - throw RelayError.encodingFailed(String(describing: error)) + private static func workspaceJSON(_ workspace: Relaycast.Workspace) -> JSONValue { + var object: [String: JSONValue] = [ + "id": .string(workspace.id), + "name": .string(workspace.name), + "created_at": .string(workspace.createdAt) + ] + if let systemPrompt = workspace.systemPrompt { + object["system_prompt"] = .string(systemPrompt) } - } - - private func encodeEmptyObject() -> Data { - Data("{}".utf8) - } - - private static func escapePath(_ value: String) -> String { - var allowed = CharacterSet.urlPathAllowed - allowed.remove(charactersIn: "/") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } - - private func isNameConflict(code: String, message: String) -> Bool { - let normalizedCode = code.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if ["agent_already_exists", "name_conflict", "name_taken", "agent_exists", "conflict", "duplicate", "http_409"].contains(normalizedCode) { - return true + if let plan = workspace.plan { + object["plan"] = .string(plan) } - return message.lowercased().contains("already exists") + if let metadata = workspace.metadata { + object["metadata"] = .object(metadata.mapValues { JSONValue($0) }) + } + return .object(object) } } -private struct RegisterAgentRequest: Encodable { - let name: String - let type: RelayAgentType -} - public final class AgentClient: @unchecked Sendable { private let core: HostedParticipantCore public let id: String @@ -302,65 +273,56 @@ private struct RegisteredAction: Sendable { let handler: RelayActionHandler } +/// Higher-level glue kept ON TOP of the relaycast engine SDK: the +/// action-dispatch loop, channel-event normalization into `RelayChannelEvent`, +/// AsyncStream fan-out, and subscription bookkeeping. The realtime socket and +/// HTTP calls are delegated to the wrapped `Relaycast.AgentClient` / +/// `Relaycast.RelayCast`. actor HostedParticipantCore { let agentId: String let agentName: String let token: String let baseURL: URL - let workspaceHTTP: any HostedHTTPClient - let agentHTTP: any HostedHTTPClient - let transport: any HostedEventTransportClient - let encoder = JSONEncoder() - let decoder = JSONDecoder() + private let engine: Relaycast.AgentClient + private let relay: Relaycast.RelayCast + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() - private var routerTask: Task? + private var connected = false + private var listenersInstalled = false private var subscribedChannels: Set = [] private var channelContinuations: [String: [AsyncStream.Continuation]] = [:] private var inboundMessageContinuations: [AsyncStream.Continuation] = [] private var eventContinuations: [AsyncStream.Continuation] = [] private var connectionStateContinuations: [AsyncStream.Continuation] = [] private var actionHandlers: [String: RegisteredAction] = [:] + private var unsubscribeHandlers: [() -> Void] = [] - init( - agentId: String, - agentName: String, - token: String, - baseURL: URL, - workspaceHTTP: any HostedHTTPClient, - agentHTTP: any HostedHTTPClient, - transport: any HostedEventTransportClient - ) { + init(engine: Relaycast.AgentClient, relay: Relaycast.RelayCast, agentId: String, agentName: String, token: String, baseURL: URL) { + self.engine = engine + self.relay = relay self.agentId = agentId self.agentName = agentName self.token = token self.baseURL = baseURL - self.workspaceHTTP = workspaceHTTP - self.agentHTTP = agentHTTP - self.transport = transport } func ensureConnected() async throws { - if routerTask == nil || routerTask?.isCancelled == true { - routerTask = Task { [weak self] in await self?.routeFrames() } - } - await transport.setOnConnect { [weak self] in - await self?.transportDidReconnect() + installListenersIfNeeded() + if !connected { + engine.connect() + connected = true } - try await transport.connect() - notifyConnectionState(.connected) - try await syncSubscriptions() - } - - func transportDidReconnect() async { notifyConnectionState(.connected) - try? await syncSubscriptions() + syncSubscriptions() } func disconnect() async { - routerTask?.cancel() - routerTask = nil - await transport.disconnect() - _ = try? await agentHTTP.post(path: "/v1/agents/disconnect", body: Data("{}".utf8)) + await engine.disconnect() + connected = false + for unsubscribe in unsubscribeHandlers { unsubscribe() } + unsubscribeHandlers.removeAll() + listenersInstalled = false notifyConnectionState(.disconnected) for continuations in channelContinuations.values { for continuation in continuations { continuation.finish() } @@ -396,14 +358,19 @@ actor HostedParticipantCore { } func post(channel: String, text: String) async throws { - let path = "/v1/channels/\(Self.escapePath(Self.normalizeChannel(channel)))/messages" - let body = try encode(SendChannelMessageRequest(text: text, mode: "wait")) - _ = try await agentHTTP.post(path: path, body: body) + do { + _ = try await engine.send(Self.normalizeChannel(channel), text: text, options: Relaycast.SendMessageOptions(mode: .wait)) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } func dm(to target: String, text: String) async throws { - let body = try encode(SendDirectMessageRequest(to: Self.stripSigil(target), text: text, mode: "wait")) - _ = try await agentHTTP.post(path: "/v1/dm", body: body) + do { + _ = try await engine.dm(Self.stripSigil(target), text: text, options: Relaycast.DMOptions(mode: .wait)) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } func registerAction( @@ -416,7 +383,7 @@ actor HostedParticipantCore { guard !actionName.isEmpty else { throw RelayError.protocolError(code: "invalid_action_name", message: "Action name cannot be empty", retryable: false) } - let inputSchema = try decodeJSONValue(inputSchemaJSON) + let inputSchema = try decodeRelaycastObject(inputSchemaJSON) let registrationId = UUID().uuidString actionHandlers[actionName] = RegisteredAction(id: registrationId, handler: handler) @@ -445,37 +412,75 @@ actor HostedParticipantCore { } } - private func registerActionDescriptor(name: String, description: String, inputSchema: JSONValue) async throws { - let request = RegisterActionDescriptorRequest( - name: name, - description: description, - handlerAgent: agentName, - inputSchema: inputSchema - ) - let body = try encode(request) - _ = try await workspaceHTTP.post(path: "/v1/actions", body: body) + private func registerActionDescriptor(name: String, description: String, inputSchema: [String: Relaycast.JSONValue]) async throws { + do { + _ = try await relay.actions.register( + Relaycast.RegisterActionRequest( + name: name, + description: description, + handlerAgent: agentName, + inputSchema: inputSchema + ) + ) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } private func unregisterActionDescriptor(name: String) async throws { - _ = try await workspaceHTTP.delete(path: "/v1/actions/\(Self.escapePath(name))") + do { + try await relay.actions.delete(name) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } - private func syncSubscriptions() async throws { + private func syncSubscriptions() { guard !subscribedChannels.isEmpty else { return } - let body = try encode(SocketSubscribeMessage(channels: Array(subscribedChannels).sorted())) - try await transport.send(body) + engine.subscribe(Array(subscribedChannels).sorted()) } - private func routeFrames() async { - for await data in transport.inbound { - guard let event = try? decoder.decode(RelayEvent.self, from: data) else { - continue - } - routeEvent(event) - } - notifyConnectionState(.disconnected) + // MARK: - Realtime listeners + + private func installListenersIfNeeded() { + guard !listenersInstalled else { return } + listenersInstalled = true + + unsubscribeHandlers.append(engine.on.messageCreated { [weak self] event in self?.ingest(event) }) + unsubscribeHandlers.append(engine.on.threadReply { [weak self] event in self?.ingest(event) }) + unsubscribeHandlers.append(engine.on.dmReceived { [weak self] event in self?.ingest(event) }) + unsubscribeHandlers.append(engine.on.groupDMReceived { [weak self] event in self?.ingest(event) }) + unsubscribeHandlers.append(engine.on.actionInvoked { [weak self] event in self?.ingest(event) }) + + unsubscribeHandlers.append(engine.on.connected { [weak self] in + guard let self else { return } + Task { await self.transportDidConnect() } + }) + unsubscribeHandlers.append(engine.on.disconnected { [weak self] in + guard let self else { return } + Task { await self.notifyConnectionStateAsync(.disconnected) } + }) + unsubscribeHandlers.append(engine.on.reconnecting { [weak self] attempt in + guard let self else { return } + Task { await self.notifyConnectionStateAsync(.reconnecting(attempt: attempt)) } + }) + } + + private nonisolated func ingest(_ event: Relaycast.WsEvent) { + Task { await self.routeEvent(RelayEvent(event)) } } + private func transportDidConnect() async { + notifyConnectionState(.connected) + syncSubscriptions() + } + + private func notifyConnectionStateAsync(_ state: ConnectionStateChange) async { + notifyConnectionState(state) + } + + // MARK: - Event routing (glue kept on top of the engine) + private func routeEvent(_ event: RelayEvent) { for continuation in eventContinuations { continuation.yield(event) @@ -543,7 +548,7 @@ actor HostedParticipantCore { ) async { do { let invocation = try await loadInvocation(actionName: actionName, invocationId: invocationId) - let input = invocation.input ?? .object([:]) + let input = invocation.input ?? [:] let inputString = actionInputString(input) let output = await registration.handler(inputString) try await completeInvocation(actionName: actionName, invocationId: invocationId, output: parseHandlerOutput(output)) @@ -552,63 +557,76 @@ actor HostedParticipantCore { } } - private func loadInvocation(actionName: String, invocationId: String) async throws -> RelayActionInvocation { - let path = "/v1/actions/\(Self.escapePath(actionName))/invocations/\(Self.escapePath(invocationId))" - let data = try await agentHTTP.get(path: path, query: nil) - return try decodeAPIData(data, as: RelayActionInvocation.self) + private func loadInvocation(actionName: String, invocationId: String) async throws -> Relaycast.ActionInvocation { + do { + return try await engine.actions.getInvocation(name: actionName, invocationID: invocationId) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } - private func completeInvocation(actionName: String, invocationId: String, output: JSONValue) async throws { - try await completeInvocation( - actionName: actionName, - invocationId: invocationId, - body: CompleteInvocationRequest(output: Self.outputRecord(output), error: nil) - ) + private func completeInvocation(actionName: String, invocationId: String, output: [String: Relaycast.JSONValue]) async throws { + do { + _ = try await engine.actions.completeInvocation( + name: actionName, + invocationID: invocationId, + data: Relaycast.CompleteInvocationRequest(output: output) + ) + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } private func completeInvocation(actionName: String, invocationId: String, error: String) async throws { - try await completeInvocation(actionName: actionName, invocationId: invocationId, body: CompleteInvocationRequest(output: nil, error: error)) + do { + _ = try await engine.actions.completeInvocation( + name: actionName, + invocationID: invocationId, + data: Relaycast.CompleteInvocationRequest(error: error) + ) + } catch let relayError as Relaycast.RelayError { + throw RelayError(relayError) + } } - private func completeInvocation(actionName: String, invocationId: String, body value: CompleteInvocationRequest) async throws { - let path = "/v1/actions/\(Self.escapePath(actionName))/invocations/\(Self.escapePath(invocationId))/complete" - _ = try await agentHTTP.post(path: path, body: try encode(value)) - } + // MARK: - JSON helpers - private func decodeJSONValue(_ json: String) throws -> JSONValue { + private func decodeRelaycastObject(_ json: String) throws -> [String: Relaycast.JSONValue] { guard let data = json.data(using: .utf8) else { throw RelayError.encodingFailed("Input schema is not valid UTF-8") } do { - return try decoder.decode(JSONValue.self, from: data) + let value = try decoder.decode(Relaycast.JSONValue.self, from: data) + guard case .object(let object) = value else { + throw RelayError.decodingFailed("Input schema must be a JSON object") + } + return object + } catch let error as RelayError { + throw error } catch { throw RelayError.decodingFailed("Invalid inputSchemaJSON: \(error)") } } - private func actionInputString(_ input: JSONValue) -> String { - guard let data = try? encoder.encode(input), let string = String(data: data, encoding: .utf8) else { + private func actionInputString(_ input: [String: Relaycast.JSONValue]) -> String { + guard let data = try? encoder.encode(Relaycast.JSONValue.object(input)), + let string = String(data: data, encoding: .utf8) else { return "{}" } return string } - private func parseHandlerOutput(_ output: String) -> JSONValue { + private func parseHandlerOutput(_ output: String) -> [String: Relaycast.JSONValue] { let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let data = trimmed.data(using: .utf8), - let value = try? decoder.decode(JSONValue.self, from: data) { - return value - } - return .string(output) - } - - private func encode(_ value: T) throws -> Data { - do { - return try encoder.encode(value) - } catch { - throw RelayError.encodingFailed(String(describing: error)) + let value = try? decoder.decode(Relaycast.JSONValue.self, from: data) { + if case .object(let object) = value { + return object + } + return ["value": value] } + return ["value": .string(output)] } private func notifyConnectionState(_ state: ConnectionStateChange) { @@ -628,12 +646,6 @@ actor HostedParticipantCore { stripSigil(value).trimmingCharacters(in: .whitespacesAndNewlines) } - private static func escapePath(_ value: String) -> String { - var allowed = CharacterSet.urlPathAllowed - allowed.remove(charactersIn: "/") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } - private static func date(from timestamp: String?) -> Date { guard let timestamp, let date = ISO8601DateFormatter().date(from: timestamp) @@ -660,64 +672,4 @@ actor HostedParticipantCore { } return error.localizedDescription } - - private static func outputRecord(_ value: JSONValue) -> JSONValue { - if case .object = value { - return value - } - return .object(["value": value]) - } -} - -private struct SendChannelMessageRequest: Encodable { - let text: String - let mode: String -} - -private struct SendDirectMessageRequest: Encodable { - let to: String - let text: String - let mode: String -} - -private struct SocketSubscribeMessage: Encodable { - let type = "subscribe" - let channels: [String] -} - -private struct RegisterActionDescriptorRequest: Encodable { - let name: String - let description: String - let handlerAgent: String - let inputSchema: JSONValue - - enum CodingKeys: String, CodingKey { - case name, description - case handlerAgent = "handler_agent" - case inputSchema = "input_schema" - } -} - -private struct CompleteInvocationRequest: Encodable { - let output: JSONValue? - let error: String? - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch (output, error) { - case (.some(let output), .none): - try container.encode(output, forKey: .output) - case (.none, .some(let error)): - try container.encode(error, forKey: .error) - default: - throw EncodingError.invalidValue( - self, - EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "completion requires output or error") - ) - } - } - - enum CodingKeys: String, CodingKey { - case output, error - } } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift deleted file mode 100644 index 28cf4a39c..000000000 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -protocol HostedHTTPClient: Sendable { - func get(path: String, query: [String: String]?) async throws -> Data - func post(path: String, body: Data?) async throws -> Data - func delete(path: String) async throws -> Data -} - -actor HostedHTTP: HostedHTTPClient { - private let baseURL: URL - private let apiKey: String - private let session: URLSession - - init(baseURL: URL, apiKey: String, session: URLSession = .shared) { - self.baseURL = baseURL - self.apiKey = apiKey - self.session = session - } - - func get(path: String, query: [String: String]? = nil) async throws -> Data { - try await request(method: "GET", path: path, query: query, body: nil) - } - - func post(path: String, body: Data?) async throws -> Data { - try await request(method: "POST", path: path, query: nil, body: body) - } - - func delete(path: String) async throws -> Data { - try await request(method: "DELETE", path: path, query: nil, body: nil) - } - - private func request(method: String, path: String, query: [String: String]?, body: Data?) async throws -> Data { - guard let url = Self.resolveAPIURL(baseURL: baseURL, path: path, query: query) else { - throw RelayError.invalidBaseURL("Could not resolve URL for path \(path)") - } - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("agent-relay-swift", forHTTPHeaderField: "X-Relaycast-Origin-Client") - request.setValue("swift-sdk-split", forHTTPHeaderField: "X-Relaycast-Origin-Version") - 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 Relaycast") - } - - if !(200..<300).contains(http.statusCode) { - let (code, message) = decodeErrorBody(data, fallbackStatus: http.statusCode) - throw RelayError.protocolError( - code: code, - message: message, - retryable: http.statusCode == 429 || http.statusCode >= 500 - ) - } - - return data - } - - static func resolveAPIURL(baseURL: URL, path: String, query: [String: String]? = nil) -> 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" } - - var basePath = components.path - while basePath.hasSuffix("/") { basePath = String(basePath.dropLast()) } - if basePath.hasSuffix("/v1/ws") { - basePath = String(basePath.dropLast("/v1/ws".count)) - } - - let normalizedPath = path.hasPrefix("/") ? path : "/" + path - components.path = basePath + normalizedPath - components.queryItems = query?.map { URLQueryItem(name: $0.key, value: $0.value) } - components.fragment = nil - return components.url - } - - private func decodeErrorBody(_ data: Data, fallbackStatus: Int) -> (code: String, message: String) { - if let envelope = try? JSONDecoder().decode(APIEnvelope.self, from: data), - let error = envelope.error { - return (error.code, error.message) - } - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let error = json["error"] as? [String: Any] - let code = (error?["code"] as? String) ?? (json["code"] as? String) ?? "http_\(fallbackStatus)" - let message = - (error?["message"] as? String) - ?? (json["message"] as? String) - ?? "HTTP \(fallbackStatus)" - return (code, message) - } - return ("http_\(fallbackStatus)", "HTTP \(fallbackStatus)") - } -} - -struct APIEnvelope: Decodable { - let ok: Bool - let data: T? - let error: APIErrorPayload? -} - -struct APIErrorPayload: Decodable { - let code: String - let message: String -} - -struct EmptyAPIData: Decodable {} - -func decodeAPIData(_ data: Data, as type: T.Type = T.self) throws -> T { - do { - let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) - if envelope.ok, let value = envelope.data { - return value - } - if envelope.ok, T.self == EmptyAPIData.self { - return EmptyAPIData() as! T - } - if let error = envelope.error { - throw RelayError.protocolError(code: error.code, message: error.message, retryable: false) - } - throw RelayError.decodingFailed("Relay API response did not include data") - } catch let error as RelayError { - throw error - } catch { - throw RelayError.decodingFailed(String(describing: error)) - } -} diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift deleted file mode 100644 index acb415477..000000000 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -protocol HostedEventTransportClient: Sendable { - var inbound: AsyncStream { get } - - func setOnConnect(_ handler: @escaping @Sendable () async -> Void) async - func connect() async throws - func disconnect() async - func send(_ message: Data) async throws -} - -public actor RelayEventTransport: HostedEventTransportClient { - public enum ConnectionState: Sendable { - case disconnected - case connecting - case connected - case reconnecting - } - - public enum TransportError: Error, Sendable { - case notConnected - case sendFailed(String) - case connectionFailed(String) - } - - public nonisolated let inbound: AsyncStream - - private let baseURL: URL - private let token: String - private let session: URLSession - private var webSocketTask: URLSessionWebSocketTask? - private var receiveTask: Task? - private var pingTask: Task? - private var reconnectTask: Task? - private var inboundContinuation: AsyncStream.Continuation? - private var state: ConnectionState = .disconnected - private var manuallyDisconnected = false - private var reconnectAttempt = 0 - private var onConnect: (@Sendable () async -> Void)? - - public init(baseURL: URL, token: String, session: URLSession = .shared) { - self.baseURL = baseURL - self.token = token - self.session = session - var continuationRef: AsyncStream.Continuation? - self.inbound = AsyncStream { continuation in - continuationRef = continuation - } - self.inboundContinuation = continuationRef - } - - public func setOnConnect(_ handler: @escaping @Sendable () async -> Void) async { - self.onConnect = handler - } - - public func connect() async throws { - switch state { - case .connected, .connecting: - return - case .disconnected, .reconnecting: - break - } - - manuallyDisconnected = false - state = reconnectAttempt == 0 ? .connecting : .reconnecting - let isReconnect = reconnectAttempt > 0 - let request = URLRequest(url: Self.resolveWebSocketURL(baseURL: baseURL, token: token) ?? baseURL) - let task = session.webSocketTask(with: request) - webSocketTask = task - task.resume() - state = .connected - reconnectAttempt = 0 - startReceiveLoop() - startPingLoop() - if isReconnect, let onConnect { - await onConnect() - } - } - - public func disconnect() async { - manuallyDisconnected = true - receiveTask?.cancel() - pingTask?.cancel() - reconnectTask?.cancel() - receiveTask = nil - pingTask = nil - reconnectTask = nil - webSocketTask?.cancel(with: .goingAway, reason: nil) - webSocketTask = nil - state = .disconnected - } - - public func send(_ message: Data) async throws { - guard let task = webSocketTask, state == .connected else { - throw TransportError.notConnected - } - do { - if let string = String(data: message, encoding: .utf8) { - try await task.send(.string(string)) - } else { - try await task.send(.data(message)) - } - } catch { - throw TransportError.sendFailed(String(describing: error)) - } - } - - static func resolveWebSocketURL(baseURL: URL, token: String) -> URL? { - var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) - if components?.scheme == "http" { components?.scheme = "ws" } - if components?.scheme == "https" { components?.scheme = "wss" } - - var path = components?.path ?? "" - while path.hasSuffix("/") { path = String(path.dropLast()) } - if path.hasSuffix("/v1/ws") { - path = String(path.dropLast("/v1/ws".count)) - } - components?.path = path + "/v1/ws" - - var queryItems = components?.queryItems?.filter { $0.name != "token" } ?? [] - queryItems.append(URLQueryItem(name: "token", value: token)) - queryItems.append(URLQueryItem(name: "origin_client", value: "agent-relay-swift")) - queryItems.append(URLQueryItem(name: "origin_version", value: "swift-sdk-split")) - components?.queryItems = queryItems - - return components?.url - } - - private func startReceiveLoop() { - receiveTask?.cancel() - receiveTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - do { - guard let task = await self.webSocketTask else { return } - let message = try await task.receive() - await self.handle(message) - } catch { - await self.handleDisconnect(error: error) - return - } - } - } - } - - private func startPingLoop() { - pingTask?.cancel() - pingTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(30)) - if Task.isCancelled { return } - try? await self.send(Self.encodeSocketMessage(["type": "ping"])) - } - } - } - - private func handle(_ message: URLSessionWebSocketTask.Message) { - switch message { - case .data(let data): - inboundContinuation?.yield(data) - case .string(let string): - if let data = string.data(using: .utf8) { - inboundContinuation?.yield(data) - } - @unknown default: - break - } - } - - private func handleDisconnect(error: Error) async { - receiveTask?.cancel() - pingTask?.cancel() - webSocketTask?.cancel(with: .goingAway, reason: nil) - webSocketTask = nil - - guard !manuallyDisconnected else { - state = .disconnected - return - } - - state = .reconnecting - let delay = reconnectDelay(for: reconnectAttempt) - reconnectAttempt += 1 - reconnectTask?.cancel() - reconnectTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: .milliseconds(delay)) - do { - try await self.connect() - } catch { - await self.handleDisconnect(error: error) - } - } - } - - private func reconnectDelay(for attempt: Int) -> Int { - switch attempt { - case 0: return 1_000 - case 1: return 2_000 - case 2: return 4_000 - case 3: return 8_000 - default: return 30_000 - } - } - - static func encodeSocketMessage(_ value: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: value) - } -} diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTypes.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTypes.swift index 4e6a764d8..ff84ecf7d 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTypes.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTypes.swift @@ -125,34 +125,6 @@ public struct RelayAgent: Decodable, Sendable { } } -struct AgentRegistrationResponse: Decodable, Sendable { - let id: String - let name: String - let token: String - let status: RelayAgentStatus - let createdAt: String? - - enum CodingKeys: String, CodingKey { - case id, name, token, status - case createdAt = "created_at" - case createdAtCamel = "createdAt" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - token = try container.decode(String.self, forKey: .token) - status = (try? container.decode(RelayAgentStatus.self, forKey: .status)) ?? .unknown - createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) - ?? container.decodeIfPresent(String.self, forKey: .createdAtCamel) - } -} - -struct RotateTokenResponse: Decodable, Sendable { - let token: String -} - public struct RelayChannelEvent: Sendable { public let from: String public let body: String @@ -270,6 +242,30 @@ public struct RelayEvent: Decodable, Sendable { public let status: String? public let rawJSON: JSONValue? + init( + type: String, + id: String? = nil, + channel: String? = nil, + message: RelayMessage? = nil, + invocationId: String? = nil, + actionName: String? = nil, + callerName: String? = nil, + agentName: String? = nil, + status: String? = nil, + rawJSON: JSONValue? = nil + ) { + self.type = type + self.id = id + self.channel = channel + self.message = message + self.invocationId = invocationId + self.actionName = actionName + self.callerName = callerName + self.agentName = agentName + self.status = status + self.rawJSON = rawJSON + } + enum CodingKeys: String, CodingKey { case type, id, channel, message, status, payload case invocationId = "invocation_id" @@ -332,36 +328,6 @@ private struct RelaycastEventAgent: Decodable { let name: String? } -struct RelayActionInvocation: Decodable, Sendable { - let invocationId: String - let actionName: String - let callerName: String? - let input: JSONValue? - let status: String - - enum CodingKeys: String, CodingKey { - case invocationId = "invocation_id" - case invocationIdCamel = "invocationId" - case actionName = "action_name" - case actionNameCamel = "actionName" - case callerName = "caller_name" - case callerNameCamel = "callerName" - case input, status - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - invocationId = try container.decodeIfPresent(String.self, forKey: .invocationId) - ?? container.decode(String.self, forKey: .invocationIdCamel) - actionName = try container.decodeIfPresent(String.self, forKey: .actionName) - ?? container.decode(String.self, forKey: .actionNameCamel) - callerName = try container.decodeIfPresent(String.self, forKey: .callerName) - ?? container.decodeIfPresent(String.self, forKey: .callerNameCamel) - input = try container.decodeIfPresent(JSONValue.self, forKey: .input) - status = (try? container.decode(String.self, forKey: .status)) ?? "invoked" - } -} - public actor ActionHandle { public nonisolated let name: String diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelaycastBridge.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelaycastBridge.swift new file mode 100644 index 000000000..7d23eb159 --- /dev/null +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelaycastBridge.swift @@ -0,0 +1,96 @@ +import Foundation +import Relaycast + +// MARK: - Type bridging between AgentRelaySDK's public surface and the +// relaycast engine SDK (`Relaycast`). These conversions let AgentRelaySDK keep +// its existing public types while delegating all transport to relaycast. + +extension RelayAgentType { + var relaycastType: Relaycast.AgentType { + switch self { + case .agent: return .agent + case .human: return .human + case .system: return .system + } + } +} + +extension RelayAgentStatus { + init(_ status: Relaycast.AgentStatus) { + switch status { + case .online: self = .online + case .offline: self = .offline + case .away: self = .away + } + } +} + +extension JSONValue { + /// Convert a relaycast `JSONValue` (which distinguishes int/double) into the + /// AgentRelaySDK `JSONValue` (number-based). + init(_ value: Relaycast.JSONValue) { + switch value { + case .null: + self = .null + case .bool(let bool): + self = .bool(bool) + case .int(let int): + self = .number(Double(int)) + case .double(let double): + self = .number(double) + case .string(let string): + self = .string(string) + case .array(let array): + self = .array(array.map { JSONValue($0) }) + case .object(let object): + self = .object(object.mapValues { JSONValue($0) }) + } + } +} + +extension RelayError { + /// Map a relaycast `RelayError` onto AgentRelaySDK's `RelayError` so callers + /// continue to see the existing error surface. + init(_ error: Relaycast.RelayError) { + switch error { + case .api(let code, let message, let statusCode, let retryable): + let normalizedCode = statusCode == 409 ? "agent_already_exists" : code + self = .protocolError(code: normalizedCode, message: message, retryable: retryable) + case .transport(let message, _, let retryable, _): + self = .protocolError(code: "transport_error", message: message, retryable: retryable) + case .invalidRequest(let message): + self = .encodingFailed(message) + case .invalidResponse(let message, _): + self = .decodingFailed(message) + case .missingData(let message): + self = .decodingFailed(message) + case .notConnected: + self = .notConnected + } + } +} + +extension RelayEvent { + /// Build an AgentRelaySDK `RelayEvent` from a relaycast realtime `WsEvent`. + /// + /// relaycast emits flat events (`type` plus payload fields at the top level), + /// while `RelayEvent` already understands both the flat and nested envelope + /// shapes via its `Decodable` implementation. Re-encode the relaycast event + /// to JSON and decode it through that flexible path so every existing field + /// extraction rule (snake/camel, nested `payload`, `agent.name`, etc.) is + /// reused verbatim. + init(_ event: Relaycast.WsEvent) { + var object: [String: Relaycast.JSONValue] = event.payload + object["type"] = .string(event.type) + + let encoder = JSONEncoder() + if let data = try? encoder.encode(Relaycast.JSONValue.object(object)), + let decoded = try? JSONDecoder().decode(RelayEvent.self, from: data) { + self = decoded + return + } + + // Fallback: preserve at least the type if re-decoding ever fails. + self = RelayEvent(type: event.type) + } +} diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 9fd92a81d..6c3d8a175 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -1,436 +1,142 @@ import Foundation import XCTest +import Relaycast @testable import AgentRelaySDK -private actor MockHostedHTTP: HostedHTTPClient { - struct Request: Sendable { - let method: String - let path: String - let body: Data? - } - - private var requests: [Request] = [] - private var getResponses: [String: Data] - private var postResponses: [String: Data] - private var postErrors: [String: Error] - - init(getResponses: [String: Data] = [:], postResponses: [String: Data] = [:], postErrors: [String: Error] = [:]) { - self.getResponses = getResponses - self.postResponses = postResponses - self.postErrors = postErrors - } - - func get(path: String, query: [String: String]?) async throws -> Data { - requests.append(Request(method: "GET", path: path, body: nil)) - return getResponses[path] ?? Self.envelope(#"{}"#) - } - - func post(path: String, body: Data?) async throws -> Data { - requests.append(Request(method: "POST", path: path, body: body)) - if let error = postErrors[path] { - throw error - } - return postResponses[path] ?? Self.envelope(#"{}"#) - } - - func delete(path: String) async throws -> Data { - requests.append(Request(method: "DELETE", path: path, body: nil)) - return Self.envelope(#"{}"#) - } - - func allRequests() -> [Request] { - requests - } - - static func envelope(_ dataJSON: String) -> Data { - Data(#"{"ok":true,"data":\#(dataJSON)}"#.utf8) - } -} - -private actor MockHostedTransport: HostedEventTransportClient { - nonisolated let inbound: AsyncStream - - private let continuation: AsyncStream.Continuation - private var sent: [Data] = [] - private var connectCount = 0 - private var onConnect: (@Sendable () async -> Void)? - - init() { - var continuationRef: AsyncStream.Continuation? - self.inbound = AsyncStream { continuation in - continuationRef = continuation - } - self.continuation = continuationRef! - } - - func setOnConnect(_ handler: @escaping @Sendable () async -> Void) async { - onConnect = handler - } - - func connect() async throws { - connectCount += 1 - } - - func disconnect() async { - continuation.finish() - } - - func send(_ message: Data) async throws { - sent.append(message) - } - - func emit(_ json: String) { - continuation.yield(Data(json.utf8)) - } - - func sentMessages() -> [Data] { - sent - } - - func connections() -> Int { - connectCount - } - - func triggerReconnect() async { - await onConnect?() - } -} - -private actor StringRecorder { - private var values: [String] = [] - - func append(_ value: String) { - values.append(value) - } - - func all() -> [String] { - values - } -} - -private func jsonObject(_ data: Data) throws -> [String: Any] { - try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) -} +/// These tests cover the hosted-participant facade that now wraps the relaycast +/// Swift engine SDK. Network-dependent behaviour (registration, posting, the +/// realtime socket) is exercised by the relaycast package's own test suite; here +/// we verify the facade configuration and the bridging glue that keeps +/// AgentRelaySDK's public surface intact on top of relaycast. +final class HostedParticipantSDKTests: XCTestCase { -private func waitForRequest( - in http: MockHostedHTTP, - timeout: TimeInterval = 1.0, - matching predicate: (MockHostedHTTP.Request) throws -> Bool -) async throws -> MockHostedHTTP.Request { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let requests = await http.allRequests() - for request in requests.reversed() { - if try predicate(request) { - return request - } - } - try await Task.sleep(nanoseconds: 10_000_000) - } - XCTFail("Timed out waiting for matching request") - return MockHostedHTTP.Request(method: "GET", path: "", body: nil) -} + // MARK: - Facade configuration -final class HostedParticipantSDKTests: XCTestCase { func testClientInitDefaultsToHostedGateway() { let client = AgentRelayClient(apiKey: "rk_test_key") XCTAssertEqual(client.workspaceKey, "rk_test_key") XCTAssertEqual(client.baseURL.absoluteString, "https://gateway.relaycast.dev") } - func testHostedWebSocketURLUsesV1WSAndToken() { - let url = RelayEventTransport.resolveWebSocketURL( - baseURL: URL(string: "https://gateway.relaycast.dev")!, - token: "at_test" - ) - XCTAssertEqual(url?.scheme, "wss") - XCTAssertEqual(url?.path, "/v1/ws") - XCTAssertTrue(url?.query?.contains("token=at_test") == true) + func testClientHonoursExplicitBaseURL() { + let client = AgentRelayClient(apiKey: "rk_test_key", baseURL: URL(string: "https://example.test")!) + XCTAssertEqual(client.baseURL.absoluteString, "https://example.test") } - func testHostedAPIURLAppendsV1Path() { - let url = HostedHTTP.resolveAPIURL(baseURL: URL(string: "https://gateway.relaycast.dev")!, path: "/v1/dm") - XCTAssertEqual(url?.absoluteString, "https://gateway.relaycast.dev/v1/dm") - } - - func testRegisterOrRotateTreatsAgentAlreadyExistsAsConflict() async throws { - let workspaceHTTP = MockHostedHTTP( - getResponses: [ - "/v1/agents/swift-agent": MockHostedHTTP.envelope( - #"{"id":"ag_existing","name":"swift-agent","type":"agent","status":"online","created_at":"2026-01-01T00:00:00Z"}"# - ) - ], - postResponses: [ - "/v1/agents/swift-agent/rotate-token": MockHostedHTTP.envelope(#"{"token":"at_rotated"}"#) - ], - postErrors: [ - "/v1/agents": RelayError.protocolError( - code: "agent_already_exists", - message: "name_taken", - retryable: false - ) - ] - ) - let core = HostedWorkspaceCore( - workspaceKey: "rk_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - http: workspaceHTTP - ) - - let registration = try await core.registerOrRotate(name: "swift-agent", type: .agent) - - XCTAssertEqual(registration.id, "ag_existing") - XCTAssertEqual(registration.name, "swift-agent") - XCTAssertEqual(registration.token, "at_rotated") - let requests = await workspaceHTTP.allRequests() - XCTAssertEqual( - requests.map { "\($0.method) \($0.path)" }, - [ - "POST /v1/agents", - "GET /v1/agents/swift-agent", - "POST /v1/agents/swift-agent/rotate-token" - ] + func testRelayCastInitUsesConfiguredHost() throws { + // Sanity-check that relaycast accepts the preserved host explicitly. + let relay = try Relaycast.RelayCast( + options: Relaycast.RelayCastOptions(apiKey: "rk_test", baseURL: "https://gateway.relaycast.dev") ) + XCTAssertEqual(relay.client.baseURL.absoluteString, "https://gateway.relaycast.dev") } - func testAgentClientPostsChannelAndDirectMessagesToHostedEndpoints() async throws { - let workspaceHTTP = MockHostedHTTP() - let agentHTTP = MockHostedHTTP() - let transport = MockHostedTransport() - let core = HostedParticipantCore( - agentId: "ag_1", - agentName: "swift-agent", - token: "at_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - workspaceHTTP: workspaceHTTP, - agentHTTP: agentHTTP, - transport: transport - ) - let client = AgentClient(core: core, id: "ag_1", name: "swift-agent", token: "at_test") - - try await client.post(to: "#general", message: "hello") - try await client.dm(to: "@reviewer", message: "ping") + // MARK: - Type bridging - let requests = await agentHTTP.allRequests() - XCTAssertEqual(requests.map(\.path), ["/v1/channels/general/messages", "/v1/dm"]) - let channelBody = try jsonObject(try XCTUnwrap(requests[0].body)) - XCTAssertEqual(channelBody["text"] as? String, "hello") - XCTAssertEqual(channelBody["mode"] as? String, "wait") - let dmBody = try jsonObject(try XCTUnwrap(requests[1].body)) - XCTAssertEqual(dmBody["to"] as? String, "reviewer") - XCTAssertEqual(dmBody["text"] as? String, "ping") + func testAgentTypeBridging() { + XCTAssertEqual(RelayAgentType.agent.relaycastType, .agent) + XCTAssertEqual(RelayAgentType.human.relaycastType, .human) + XCTAssertEqual(RelayAgentType.system.relaycastType, .system) } - func testChannelSubscribeSendsHostedSubscribeFrameAndRoutesMessages() async throws { - let transport = MockHostedTransport() - let core = HostedParticipantCore( - agentId: "ag_1", - agentName: "swift-agent", - token: "at_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - workspaceHTTP: MockHostedHTTP(), - agentHTTP: MockHostedHTTP(), - transport: transport - ) - let client = AgentClient(core: core, id: "ag_1", name: "swift-agent", token: "at_test") - let channel = client.channel("general") - try await channel.subscribe() - var sentMessages = await transport.sentMessages() - XCTAssertEqual(sentMessages.count, 1) - await transport.triggerReconnect() - sentMessages = await transport.sentMessages() - XCTAssertEqual(sentMessages.count, 2) - - let messages = channel.events - let task = Task { () -> RelayChannelEvent? in - for await event in messages { - return event - } - return nil - } - - await transport.emit( - """ - { - "type": "message.received", - "payload": { - "channel": "general", - "message": { - "id": "msg_1", - "body": "hello", - "from": {"name": "alice"}, - "channel": {"name": "general"} - } - } - } - """ - ) - - let maybeEvent = await task.value - let event = try XCTUnwrap(maybeEvent) - XCTAssertEqual(event.from, "alice") - XCTAssertEqual(event.body, "hello") - XCTAssertEqual(event.channel, "general") + func testAgentStatusBridging() { + XCTAssertEqual(RelayAgentStatus(Relaycast.AgentStatus.online), .online) + XCTAssertEqual(RelayAgentStatus(Relaycast.AgentStatus.offline), .offline) + XCTAssertEqual(RelayAgentStatus(Relaycast.AgentStatus.away), .away) } - func testRegisterActionPostsDescriptorAndCompletesHostedInvocation() async throws { - let invocationPath = "/v1/actions/echo/invocations/inv_1" - let completionPath = "\(invocationPath)/complete" - let workspaceHTTP = MockHostedHTTP() - let agentHTTP = MockHostedHTTP( - getResponses: [ - invocationPath: MockHostedHTTP.envelope( - #"{"invocation_id":"inv_1","action_name":"echo","caller_name":"alice","input":{"text":"hello"},"status":"invoked"}"# - ) - ], - postResponses: [ - completionPath: MockHostedHTTP.envelope( - #"{"invocation_id":"inv_1","action_name":"echo","status":"completed","output":{"ok":true},"error":null,"duration_ms":1,"completed_at":"2026-01-01T00:00:00Z"}"# - ) - ] - ) - let transport = MockHostedTransport() - let core = HostedParticipantCore( - agentId: "ag_1", - agentName: "swift-agent", - token: "at_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - workspaceHTTP: workspaceHTTP, - agentHTTP: agentHTTP, - transport: transport + func testJSONValueBridgingPreservesShape() { + let relaycastValue: Relaycast.JSONValue = .object([ + "text": .string("hi"), + "count": .int(3), + "ratio": .double(1.5), + "flag": .bool(true), + "items": .array([.string("a"), .int(2)]), + "nothing": .null + ]) + let bridged = JSONValue(relaycastValue) + guard case .object(let object) = bridged else { + return XCTFail("Expected object") + } + XCTAssertEqual(object["text"], .string("hi")) + XCTAssertEqual(object["count"], .number(3)) + XCTAssertEqual(object["ratio"], .number(1.5)) + XCTAssertEqual(object["flag"], .bool(true)) + XCTAssertEqual(object["items"], .array([.string("a"), .number(2)])) + XCTAssertEqual(object["nothing"], .null) + } + + func testErrorBridgingMapsConflictToAlreadyExists() { + let relaycastError = Relaycast.RelayError.api( + code: "some_code", + message: "name_taken", + statusCode: 409, + retryable: false ) - let client = AgentClient(core: core, id: "ag_1", name: "swift-agent", token: "at_test") - let recorder = StringRecorder() - - _ = try await client.registerAction( - name: "echo", - description: "Echo input", - inputSchemaJSON: #"{"type":"object","properties":{"text":{"type":"string"}}}"# - ) { input in - await recorder.append(input) - return #"{"ok":true}"# + let bridged = RelayError(relaycastError) + guard case .protocolError(let code, let message, _) = bridged else { + return XCTFail("Expected protocolError") } + XCTAssertEqual(code, "agent_already_exists") + XCTAssertEqual(message, "name_taken") + } - let descriptor = try await waitForRequest(in: workspaceHTTP) { request in - request.method == "POST" && request.path == "/v1/actions" + func testErrorBridgingMapsNotConnected() { + if case .notConnected = RelayError(Relaycast.RelayError.notConnected) { + // ok + } else { + XCTFail("Expected notConnected") } - let descriptorBody = try jsonObject(try XCTUnwrap(descriptor.body)) - XCTAssertEqual(descriptorBody["name"] as? String, "echo") - XCTAssertEqual(descriptorBody["description"] as? String, "Echo input") - XCTAssertEqual(descriptorBody["handler_agent"] as? String, "swift-agent") - XCTAssertEqual((descriptorBody["input_schema"] as? [String: Any])?["type"] as? String, "object") + } - await transport.emit( - """ - { - "type": "action.invoked", - "payload": { - "type": "action.invoked", - "invocation_id": "inv_1", - "action_name": "echo", - "caller_name": "alice" - } - } - """ - ) + // MARK: - Realtime event glue - let completion = try await waitForRequest(in: agentHTTP) { request in - request.method == "POST" && request.path == completionPath - } - let completionBody = try jsonObject(try XCTUnwrap(completion.body)) - XCTAssertEqual((completionBody["output"] as? [String: Any])?["ok"] as? Bool, true) + func testRelayEventFromWsEventExtractsMessageFields() { + // relaycast emits flat events: type plus payload fields at the top level. + let wsEvent = Relaycast.WsEvent(type: "message.created", payload: [ + "channel": .string("general"), + "message": .object([ + "id": .string("msg_1"), + "message_id": .string("msg_1"), + "body": .string("hello"), + "from": .object(["name": .string("alice")]), + "channel": .object(["name": .string("general")]) + ]) + ]) - let inputs = await recorder.all() - XCTAssertEqual(inputs.count, 1) - let input = try jsonObject(Data(inputs[0].utf8)) - XCTAssertEqual(input["text"] as? String, "hello") + let event = RelayEvent(wsEvent) + XCTAssertEqual(event.type, "message.created") + XCTAssertEqual(event.channel, "general") + XCTAssertEqual(event.message?.text, "hello") + XCTAssertEqual(event.message?.from.name, "alice") + XCTAssertEqual(event.message?.channel?.name, "general") } - func testRegisterActionWrapsScalarHandlerOutput() async throws { - let invocationPath = "/v1/actions/plain/invocations/inv_scalar" - let completionPath = "\(invocationPath)/complete" - let workspaceHTTP = MockHostedHTTP() - let agentHTTP = MockHostedHTTP( - getResponses: [ - invocationPath: MockHostedHTTP.envelope( - #"{"invocation_id":"inv_scalar","action_name":"plain","caller_name":"alice","input":{},"status":"invoked"}"# - ) - ], - postResponses: [ - completionPath: MockHostedHTTP.envelope( - #"{"invocation_id":"inv_scalar","action_name":"plain","status":"completed","output":{"value":"done"},"error":null}"# - ) - ] - ) - let transport = MockHostedTransport() - let core = HostedParticipantCore( - agentId: "ag_1", - agentName: "swift-agent", - token: "at_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - workspaceHTTP: workspaceHTTP, - agentHTTP: agentHTTP, - transport: transport - ) - let client = AgentClient(core: core, id: "ag_1", name: "swift-agent", token: "at_test") - - _ = try await client.registerAction( - name: "plain", - description: "Plain output", - inputSchemaJSON: #"{"type":"object"}"# - ) { _ in - "done" - } + func testRelayEventFromWsEventExtractsActionInvocationFields() { + let wsEvent = Relaycast.WsEvent(type: "action.invoked", payload: [ + "invocation_id": .string("inv_1"), + "action_name": .string("echo"), + "caller_name": .string("alice") + ]) - await transport.emit( - """ - { - "type": "action.invoked", - "payload": { - "action_name": "plain", - "invocation_id": "inv_scalar", - "caller_name": "alice" - } - } - """ - ) + let event = RelayEvent(wsEvent) + XCTAssertEqual(event.type, "action.invoked") + XCTAssertEqual(event.invocationId, "inv_1") + XCTAssertEqual(event.actionName, "echo") + XCTAssertEqual(event.callerName, "alice") + } - let completion = try await waitForRequest(in: agentHTTP) { request in - request.method == "POST" && request.path == completionPath - } - let completionBody = try jsonObject(try XCTUnwrap(completion.body)) - XCTAssertEqual((completionBody["output"] as? [String: Any])?["value"] as? String, "done") + func testRelayEventFromWsEventPreservesTypeWithEmptyPayload() { + let event = RelayEvent(Relaycast.WsEvent(type: "pong")) + XCTAssertEqual(event.type, "pong") + XCTAssertNil(event.message) } - func testUnregisterActionDeletesHostedDescriptor() async throws { - let workspaceHTTP = MockHostedHTTP() - let core = HostedParticipantCore( - agentId: "ag_1", - agentName: "swift-agent", - token: "at_test", - baseURL: URL(string: "https://gateway.relaycast.dev")!, - workspaceHTTP: workspaceHTTP, - agentHTTP: MockHostedHTTP(), - transport: MockHostedTransport() - ) - let client = AgentClient(core: core, id: "ag_1", name: "swift-agent", token: "at_test") + // MARK: - Action handle lifecycle - let handle = try await client.registerAction( - name: "cleanup", - description: "Cleanup", - inputSchemaJSON: #"{"type":"object"}"# - ) { _ in - "{}" - } + func testActionHandleExposesName() async { + let handle = ActionHandle(name: "echo") { } + XCTAssertEqual(handle.name, "echo") await handle.unregister() - - let requests = await workspaceHTTP.allRequests() - XCTAssertTrue(requests.contains { $0.method == "DELETE" && $0.path == "/v1/actions/cleanup" }) - } -} - -private extension HostedParticipantCore { - func transportAsMock() -> MockHostedTransport? { - transport as? MockHostedTransport } } From 641f4dd07e16bc2be360b03bcc93a6fe5117e5d0 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 08:58:09 -0400 Subject: [PATCH 2/7] fix(sdk-swift): resolve relaycast via git URL; keep registrations usable Comment 1 (CI-blocking): replace the committed external symlink (.relaycast-swift -> ../../../relaycast/packages/sdk-swift), which does not resolve on clean checkouts or for normal SPM consumers, with a git-URL SwiftPM dependency on AgentWorkforce/relaycast pinned to the revision of the new root-level Package.swift packaging branch. The relaycast root manifest (AgentWorkforce/relaycast#208) vends the Relaycast library/target so it can be consumed by git URL. Update the README accordingly and drop the symlink. Comment 2: AgentRegistration.asClient() previously captured HostedWorkspaceCore weakly and trapped in fatalError if the owning AgentRelay was released first. Capture the relay/baseURL transport state strongly so a persisted AgentRegistration stays usable independently, preserving the public contract. Add a regression test (testRegistrationAsClientSurvivesCoreRelease). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/.relaycast-swift | 1 - packages/sdk-swift/Package.swift | 43 ++++++++----------- packages/sdk-swift/README.md | 31 ++++++------- .../AgentRelaySDK/AgentRelayClient.swift | 28 +++++++++--- .../AgentRelaySDKTests.swift | 31 +++++++++++++ 5 files changed, 83 insertions(+), 51 deletions(-) delete mode 120000 packages/sdk-swift/.relaycast-swift diff --git a/packages/sdk-swift/.relaycast-swift b/packages/sdk-swift/.relaycast-swift deleted file mode 120000 index 151485d7c..000000000 --- a/packages/sdk-swift/.relaycast-swift +++ /dev/null @@ -1 +0,0 @@ -../../../relaycast/packages/sdk-swift \ No newline at end of file diff --git a/packages/sdk-swift/Package.swift b/packages/sdk-swift/Package.swift index 15c8086ec..bb5c71990 100644 --- a/packages/sdk-swift/Package.swift +++ b/packages/sdk-swift/Package.swift @@ -1,35 +1,23 @@ // swift-tools-version: 5.9 import PackageDescription -import Foundation // The hosted-participant transport (`AgentRelaySDK`) is a thin facade over the -// published relaycast Swift engine SDK (product `Relaycast`, package -// `relaycast-swift`). +// relaycast Swift engine SDK (product `Relaycast`). // -// relaycast-swift lives in a subdirectory of the relaycast monorepo -// (`packages/sdk-swift`), so SwiftPM cannot consume it directly via a git URL -// (git dependencies require `Package.swift` at the repository root). Until a -// root-level mirror/tag is published, this package references relaycast-swift by -// a local checkout. To build against a published version instead, replace the -// `.package(...)` entry below with a git/registry reference whose root is the -// relaycast Swift package, e.g.: +// relaycast's Swift SDK lives in a subdirectory of the relaycast monorepo +// (`packages/sdk-swift`), so it cannot be consumed as a plain git-URL SwiftPM +// dependency on its own (git dependencies require `Package.swift` at the +// repository root). A root-level manifest that vends the `Relaycast` library is +// added in the relaycast monorepo (see AgentWorkforce/relaycast#208); this +// package depends on it via that repository's git URL. // -// .package(url: "https://github.com/AgentWorkforce/relaycast-swift.git", from: "4.1.6") +// The dependency is pinned to a specific revision of the `swift-root-package` +// branch until that PR is merged and the monorepo is re-tagged. Once a tag that +// includes the root manifest is published, replace `revision:` with +// `from: "x.y.z"`: // -// (matching one of the relaycast monorepo tags, e.g. v4.1.6). +// .package(url: "https://github.com/AgentWorkforce/relaycast.git", from: "4.1.7") // -// Local-checkout details: -// * The relaycast Swift package directory is named `sdk-swift` — the same -// basename as THIS package. SwiftPM derives a path dependency's identity -// from that basename, so referencing it directly collides with this -// package's own identity. To avoid the collision we depend on the relaycast -// package through the uniquely named symlink committed alongside this -// manifest: `.relaycast-swift`. It points (relative) at a sibling clone of -// the relaycast monorepo: -// .relaycast-swift -> ../../../relaycast/packages/sdk-swift -// * To point at a checkout in a different location, repoint that symlink, e.g. -// ln -sfn /path/to/relaycast/packages/sdk-swift \ -// packages/sdk-swift/.relaycast-swift let package = Package( name: "AgentRelaySDK", platforms: [ @@ -43,13 +31,16 @@ let package = Package( .library(name: "AgentRelayBrokerSDK", targets: ["AgentRelayBrokerSDK"]) ], dependencies: [ - .package(name: "relaycast-swift", path: ".relaycast-swift") + .package( + url: "https://github.com/AgentWorkforce/relaycast.git", + revision: "24c9140824518bf371a6c09f8be1f2a298efaf56" + ) ], targets: [ .target( name: "AgentRelaySDK", dependencies: [ - .product(name: "Relaycast", package: "relaycast-swift") + .product(name: "Relaycast", package: "relaycast") ], path: "Sources/AgentRelaySDK" ), diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index cc6a189a2..f7d85ad31 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -30,26 +30,23 @@ WebSocket work is delegated to relaycast; `AgentRelaySDK` keeps only the relay-specific glue (action-dispatch loop, `RelayChannelEvent` shape, and the `AsyncStream`-based public API). -Because relaycast-swift is not at its repository root, SwiftPM cannot consume it -by git URL directly, and its directory basename (`sdk-swift`) collides with this -package's own identity. The dependency is therefore wired through a committed -symlink (`packages/sdk-swift/.relaycast-swift`) that points at a sibling clone of -the relaycast monorepo: +relaycast's Swift SDK lives in a subdirectory of the relaycast monorepo +(`packages/sdk-swift`), so it cannot be consumed as a plain git-URL SwiftPM +dependency on its own (git dependencies require `Package.swift` at the +repository root). A root-level manifest that vends the `Relaycast` library is +added to the relaycast monorepo (see +[AgentWorkforce/relaycast#208](https://github.com/AgentWorkforce/relaycast/pull/208)), +and this package depends on it via that repository's git URL: -``` -.relaycast-swift -> ../../../relaycast/packages/sdk-swift -``` - -To use a relaycast checkout in a different location, repoint that symlink: - -```sh -ln -sfn /path/to/relaycast/packages/sdk-swift packages/sdk-swift/.relaycast-swift +```swift +.package(url: "https://github.com/AgentWorkforce/relaycast.git", revision: "") ``` -Once a root-level mirror/tag of relaycast-swift is published, replace the path -dependency in `Package.swift` with a git/registry reference, e.g. -`.package(url: "https://github.com/AgentWorkforce/relaycast-swift.git", from: "4.1.6")` -(matching a relaycast monorepo tag such as `v4.1.6`). +The dependency is pinned to a revision of the `swift-root-package` branch until +that PR is merged and the relaycast monorepo is re-tagged. Once a tag that +includes the root manifest is published, replace `revision:` with a version +requirement, e.g. +`.package(url: "https://github.com/AgentWorkforce/relaycast.git", from: "4.1.7")`. ## Quick start diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index 2d01d0fde..dcd0f9a54 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -131,22 +131,36 @@ final class HostedWorkspaceCore: @unchecked Sendable { } func makeParticipantCore(id: String, name: String, token: String) -> HostedParticipantCore { + Self.makeParticipantCore(relay: relay, baseURL: baseURL, id: id, name: name, token: token) + } + + static func makeParticipantCore(relay: Relaycast.RelayCast, baseURL: URL, id: String, name: String, token: String) -> HostedParticipantCore { let engine = (try? relay.asAgent(token))! return HostedParticipantCore(engine: engine, relay: relay, agentId: id, agentName: name, token: token, baseURL: baseURL) } - private func makeRegistration(_ response: Relaycast.CreateAgentResponse) -> AgentRegistration { - AgentRegistration( + func makeRegistration(_ response: Relaycast.CreateAgentResponse) -> AgentRegistration { + // Capture the transport state (`relay`, `baseURL`) strongly so a + // persisted `AgentRegistration` stays usable even if the owning + // `AgentRelay`/`HostedWorkspaceCore` is released before `asClient()` is + // called. `Relaycast.RelayCast` is the only shared, reusable state; the + // per-agent engine is created lazily from it in the closure. + let relay = self.relay + let baseURL = self.baseURL + return AgentRegistration( id: response.id, name: response.name, token: response.token, status: RelayAgentStatus(response.status), createdAt: response.createdAt - ) { [weak self] id, agentName, token in - guard let self else { - fatalError("HostedWorkspaceCore deallocated before AgentRegistration.asClient()") - } - let core = self.makeParticipantCore(id: id, name: agentName, token: token) + ) { id, agentName, token in + let core = HostedWorkspaceCore.makeParticipantCore( + relay: relay, + baseURL: baseURL, + id: id, + name: agentName, + token: token + ) return AgentClient(core: core, id: id, name: agentName, token: token) } } diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 6c3d8a175..64301e5f6 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -132,6 +132,37 @@ final class HostedParticipantSDKTests: XCTestCase { XCTAssertNil(event.message) } + // MARK: - Registration lifecycle + + /// A persisted `AgentRegistration` must stay usable even if the owning + /// `AgentRelay`/`HostedWorkspaceCore` is released before `asClient()` is + /// called. The factory captures the relay/baseURL transport state strongly, + /// so this must not trap. + func testRegistrationAsClientSurvivesCoreRelease() throws { + let response = try makeCreateAgentResponse(id: "agent_1", name: "swift-agent", token: "rk_token") + + var core: HostedWorkspaceCore? = HostedWorkspaceCore( + workspaceKey: "rk_test_key", + baseURL: URL(string: "https://gateway.relaycast.dev")! + ) + let registration = core!.makeRegistration(response) + + // Drop the owning core before using the persisted registration. + core = nil + + let client = registration.asClient() + XCTAssertEqual(client.id, "agent_1") + XCTAssertEqual(client.name, "swift-agent") + XCTAssertEqual(client.token, "rk_token") + } + + private func makeCreateAgentResponse(id: String, name: String, token: String) throws -> Relaycast.CreateAgentResponse { + let json = """ + {"id":"\(id)","name":"\(name)","token":"\(token)","status":"online","createdAt":"2026-06-20T00:00:00Z"} + """ + return try JSONDecoder().decode(Relaycast.CreateAgentResponse.self, from: Data(json.utf8)) + } + // MARK: - Action handle lifecycle func testActionHandleExposesName() async { From a40bcb3e42668fc72542a7e79b0923d92efcba63 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 09:20:04 -0400 Subject: [PATCH 3/7] fix(sdk-swift): propagate relaycast init errors and serialize inbound events Address CodeRabbit review on PR #1188: - Stop force-unwrapping `try? RelayCast(options:)` and `try? relay.asAgent`. Both can throw on bad config (empty apiKey / invalid baseURL) and were crashing the process. The RelayCast build result is captured eagerly and rethrown as a translated `RelayError` on first use; the per-agent engine is built lazily inside the actor so `asAgent` errors propagate through the first async call. Public (non-throwing) initializers are unchanged. - Reset `connected = false` on engine-initiated disconnect so `ensureConnected()` re-issues `engine.connect()`, matching the manual `disconnect()` path. - Route inbound engine events through a single serialized AsyncStream drained by one consumer task instead of spawning an unstructured Task per event, so closely-spaced events keep their delivery order. The pump is torn down on disconnect. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/Package.resolved | 13 ++ .../AgentRelaySDK/AgentRelayClient.swift | 192 ++++++++++++++---- 2 files changed, 170 insertions(+), 35 deletions(-) create mode 100644 packages/sdk-swift/Package.resolved diff --git a/packages/sdk-swift/Package.resolved b/packages/sdk-swift/Package.resolved new file mode 100644 index 000000000..cd9a06e4b --- /dev/null +++ b/packages/sdk-swift/Package.resolved @@ -0,0 +1,13 @@ +{ + "pins" : [ + { + "identity" : "relaycast", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AgentWorkforce/relaycast.git", + "state" : { + "revision" : "24c9140824518bf371a6c09f8be1f2a298efaf56" + } + } + ], + "version" : 2 +} diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index dcd0f9a54..d579fe01a 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -69,21 +69,39 @@ public typealias AgentRelayClient = AgentRelay final class HostedWorkspaceCore: @unchecked Sendable { let workspaceKey: String let baseURL: URL - let relay: Relaycast.RelayCast + // `Relaycast.RelayCast(options:)` can throw (e.g. empty apiKey, invalid + // baseURL). The public `AgentRelay` initializers are non-throwing, so we + // capture the construction result eagerly and rethrow a translated + // `RelayError` on first use instead of force-unwrapping (which would crash + // the process on bad configuration). + private let relayResult: Result init(workspaceKey: String, baseURL: URL) { self.workspaceKey = workspaceKey self.baseURL = baseURL // PRESERVE the configured host: pass it explicitly into relaycast. - self.relay = (try? Relaycast.RelayCast( - options: Relaycast.RelayCastOptions( - apiKey: workspaceKey, - baseURL: baseURL.absoluteString + self.relayResult = Result { + try Relaycast.RelayCast( + options: Relaycast.RelayCastOptions( + apiKey: workspaceKey, + baseURL: baseURL.absoluteString + ) ) - ))! + } + } + + /// Resolve the wrapped relaycast engine, surfacing configuration errors as + /// `RelayError` rather than crashing. + func relayCast() throws -> Relaycast.RelayCast { + do { + return try relayResult.get() + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } } func register(name: String, type: RelayAgentType) async throws -> AgentRegistration { + let relay = try relayCast() do { let created = try await relay.agents.register( Relaycast.CreateAgentRequest(name: name, type: type.relaycastType) @@ -95,6 +113,7 @@ final class HostedWorkspaceCore: @unchecked Sendable { } func registerOrRotate(name: String, type: RelayAgentType) async throws -> AgentRegistration { + let relay = try relayCast() do { let created = try await relay.registerOrRotate( Relaycast.RegisterAgentRequest(name: name, type: type.relaycastType) @@ -106,10 +125,11 @@ final class HostedWorkspaceCore: @unchecked Sendable { } func reconnect(apiToken: String) async throws -> AgentClient { + let relay = try relayCast() do { let engine = try await relay.reconnect(Relaycast.AgentReconnectOptions(apiToken: apiToken)) let me = try await engine.me() - let core = HostedParticipantCore(engine: engine, relay: relay, agentId: me.id, agentName: me.name, token: apiToken, baseURL: baseURL) + let core = HostedParticipantCore(engineSource: .ready(engine: engine, relay: relay), agentId: me.id, agentName: me.name, token: apiToken, baseURL: baseURL) return AgentClient(core: core, id: me.id, name: me.name, token: apiToken) } catch let error as Relaycast.RelayError { throw RelayError(error) @@ -122,6 +142,7 @@ final class HostedWorkspaceCore: @unchecked Sendable { } func workspaceInfo() async throws -> JSONValue { + let relay = try relayCast() do { let workspace = try await relay.workspace.info() return Self.workspaceJSON(workspace) @@ -131,21 +152,23 @@ final class HostedWorkspaceCore: @unchecked Sendable { } func makeParticipantCore(id: String, name: String, token: String) -> HostedParticipantCore { - Self.makeParticipantCore(relay: relay, baseURL: baseURL, id: id, name: name, token: token) + Self.makeParticipantCore(relayResult: relayResult, baseURL: baseURL, id: id, name: name, token: token) } - static func makeParticipantCore(relay: Relaycast.RelayCast, baseURL: URL, id: String, name: String, token: String) -> HostedParticipantCore { - let engine = (try? relay.asAgent(token))! - return HostedParticipantCore(engine: engine, relay: relay, agentId: id, agentName: name, token: token, baseURL: baseURL) + static func makeParticipantCore(relayResult: Result, baseURL: URL, id: String, name: String, token: String) -> HostedParticipantCore { + // Defer the per-agent engine build (`relay.asAgent`) into the actor so + // that configuration/handshake errors propagate through the first async + // call as `RelayError` instead of force-unwrapping and crashing. + return HostedParticipantCore(engineSource: .deferred(relayResult: relayResult, token: token), agentId: id, agentName: name, token: token, baseURL: baseURL) } func makeRegistration(_ response: Relaycast.CreateAgentResponse) -> AgentRegistration { - // Capture the transport state (`relay`, `baseURL`) strongly so a + // Capture the transport state (`relayResult`, `baseURL`) strongly so a // persisted `AgentRegistration` stays usable even if the owning // `AgentRelay`/`HostedWorkspaceCore` is released before `asClient()` is // called. `Relaycast.RelayCast` is the only shared, reusable state; the // per-agent engine is created lazily from it in the closure. - let relay = self.relay + let relayResult = self.relayResult let baseURL = self.baseURL return AgentRegistration( id: response.id, @@ -155,7 +178,7 @@ final class HostedWorkspaceCore: @unchecked Sendable { createdAt: response.createdAt ) { id, agentName, token in let core = HostedWorkspaceCore.makeParticipantCore( - relay: relay, + relayResult: relayResult, baseURL: baseURL, id: id, name: agentName, @@ -293,12 +316,22 @@ private struct RegisteredAction: Sendable { /// HTTP calls are delegated to the wrapped `Relaycast.AgentClient` / /// `Relaycast.RelayCast`. actor HostedParticipantCore { + /// How the per-agent engine is obtained. `.ready` is used by `reconnect`, + /// which already has a live engine; `.deferred` builds the engine lazily via + /// `relay.asAgent(token)` on first connect so that `asAgent`/configuration + /// errors propagate as `RelayError` instead of crashing at construction. + enum EngineSource { + case ready(engine: Relaycast.AgentClient, relay: Relaycast.RelayCast) + case deferred(relayResult: Result, token: String) + } + let agentId: String let agentName: String let token: String let baseURL: URL - private let engine: Relaycast.AgentClient - private let relay: Relaycast.RelayCast + private let engineSource: EngineSource + private var resolvedEngine: Relaycast.AgentClient? + private var resolvedRelay: Relaycast.RelayCast? private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -312,31 +345,86 @@ actor HostedParticipantCore { private var actionHandlers: [String: RegisteredAction] = [:] private var unsubscribeHandlers: [() -> Void] = [] - init(engine: Relaycast.AgentClient, relay: Relaycast.RelayCast, agentId: String, agentName: String, token: String, baseURL: URL) { - self.engine = engine - self.relay = relay + // Serialized inbound-event pipeline. The engine delivers events in order + // from a single receive loop; we yield them (synchronously, FIFO) into this + // stream and drain them through `routeEvent` on one consumer task so that + // ordering is preserved end-to-end. (Spawning an unstructured `Task` per + // event would let the scheduler reorder closely-spaced events.) + private var eventBuffer: AsyncStream.Continuation? + private var eventPump: Task? + + init(engineSource: EngineSource, agentId: String, agentName: String, token: String, baseURL: URL) { + self.engineSource = engineSource self.agentId = agentId self.agentName = agentName self.token = token self.baseURL = baseURL } + /// Resolve (and cache) the relaycast `RelayCast` engine wrapper. Surfaces + /// configuration errors as `RelayError`. + private func relayCast() throws -> Relaycast.RelayCast { + if let resolvedRelay { return resolvedRelay } + switch engineSource { + case .ready(_, let relay): + resolvedRelay = relay + return relay + case .deferred(let relayResult, _): + do { + let relay = try relayResult.get() + resolvedRelay = relay + return relay + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } + } + } + + /// Resolve (and cache) the per-agent engine, building it lazily for the + /// `.deferred` source. Surfaces `asAgent`/configuration errors as `RelayError`. + private func engine() throws -> Relaycast.AgentClient { + if let resolvedEngine { return resolvedEngine } + switch engineSource { + case .ready(let engine, _): + resolvedEngine = engine + return engine + case .deferred(_, let token): + let relay = try relayCast() + do { + let engine = try relay.asAgent(token) + resolvedEngine = engine + return engine + } catch let error as Relaycast.RelayError { + throw RelayError(error) + } + } + } + func ensureConnected() async throws { - installListenersIfNeeded() + let engine = try engine() + installListenersIfNeeded(engine: engine) if !connected { engine.connect() connected = true } notifyConnectionState(.connected) - syncSubscriptions() + syncSubscriptions(engine: engine) } func disconnect() async { - await engine.disconnect() + // Only tear down an engine that was actually built/connected; building + // one here just to disconnect it would be pointless (and could throw). + if let resolvedEngine { + await resolvedEngine.disconnect() + } connected = false for unsubscribe in unsubscribeHandlers { unsubscribe() } unsubscribeHandlers.removeAll() listenersInstalled = false + eventBuffer?.finish() + eventBuffer = nil + eventPump?.cancel() + eventPump = nil notifyConnectionState(.disconnected) for continuations in channelContinuations.values { for continuation in continuations { continuation.finish() } @@ -372,6 +460,7 @@ actor HostedParticipantCore { } func post(channel: String, text: String) async throws { + let engine = try engine() do { _ = try await engine.send(Self.normalizeChannel(channel), text: text, options: Relaycast.SendMessageOptions(mode: .wait)) } catch let error as Relaycast.RelayError { @@ -380,6 +469,7 @@ actor HostedParticipantCore { } func dm(to target: String, text: String) async throws { + let engine = try engine() do { _ = try await engine.dm(Self.stripSigil(target), text: text, options: Relaycast.DMOptions(mode: .wait)) } catch let error as Relaycast.RelayError { @@ -427,6 +517,7 @@ actor HostedParticipantCore { } private func registerActionDescriptor(name: String, description: String, inputSchema: [String: Relaycast.JSONValue]) async throws { + let relay = try relayCast() do { _ = try await relay.actions.register( Relaycast.RegisterActionRequest( @@ -442,6 +533,7 @@ actor HostedParticipantCore { } private func unregisterActionDescriptor(name: String) async throws { + let relay = try relayCast() do { try await relay.actions.delete(name) } catch let error as Relaycast.RelayError { @@ -449,22 +541,40 @@ actor HostedParticipantCore { } } - private func syncSubscriptions() { + private func syncSubscriptions(engine: Relaycast.AgentClient) { guard !subscribedChannels.isEmpty else { return } engine.subscribe(Array(subscribedChannels).sorted()) } // MARK: - Realtime listeners - private func installListenersIfNeeded() { + private func installListenersIfNeeded(engine: Relaycast.AgentClient) { guard !listenersInstalled else { return } listenersInstalled = true - unsubscribeHandlers.append(engine.on.messageCreated { [weak self] event in self?.ingest(event) }) - unsubscribeHandlers.append(engine.on.threadReply { [weak self] event in self?.ingest(event) }) - unsubscribeHandlers.append(engine.on.dmReceived { [weak self] event in self?.ingest(event) }) - unsubscribeHandlers.append(engine.on.groupDMReceived { [weak self] event in self?.ingest(event) }) - unsubscribeHandlers.append(engine.on.actionInvoked { [weak self] event in self?.ingest(event) }) + // Build the serialized event pipeline before wiring engine callbacks so + // the first delivered event already has a place to queue. + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { continuation = $0 } + eventBuffer = continuation + eventPump = Task { [weak self] in + for await event in stream { + await self?.routeEvent(event) + } + } + + // `engine.on.*` fire in order from the engine's single receive loop; + // yielding into `buffer` (synchronously) preserves that order, and the + // single `eventPump` consumer drains them sequentially. + let buffer = continuation! + let ingest: @Sendable (Relaycast.WsEvent) -> Void = { event in + buffer.yield(RelayEvent(event)) + } + unsubscribeHandlers.append(engine.on.messageCreated(ingest)) + unsubscribeHandlers.append(engine.on.threadReply(ingest)) + unsubscribeHandlers.append(engine.on.dmReceived(ingest)) + unsubscribeHandlers.append(engine.on.groupDMReceived(ingest)) + unsubscribeHandlers.append(engine.on.actionInvoked(ingest)) unsubscribeHandlers.append(engine.on.connected { [weak self] in guard let self else { return } @@ -472,7 +582,7 @@ actor HostedParticipantCore { }) unsubscribeHandlers.append(engine.on.disconnected { [weak self] in guard let self else { return } - Task { await self.notifyConnectionStateAsync(.disconnected) } + Task { await self.handleEngineDisconnect() } }) unsubscribeHandlers.append(engine.on.reconnecting { [weak self] attempt in guard let self else { return } @@ -480,19 +590,28 @@ actor HostedParticipantCore { }) } - private nonisolated func ingest(_ event: Relaycast.WsEvent) { - Task { await self.routeEvent(RelayEvent(event)) } - } - private func transportDidConnect() async { notifyConnectionState(.connected) - syncSubscriptions() + // The engine is necessarily resolved here: this fires from the engine's + // own `connected` callback, which is only installed after `engine()` ran. + if let resolvedEngine { + syncSubscriptions(engine: resolvedEngine) + } } private func notifyConnectionStateAsync(_ state: ConnectionStateChange) async { notifyConnectionState(state) } + /// Handle an engine-initiated disconnect. Reset `connected` so that a later + /// `ensureConnected()` will actually re-issue `engine.connect()`, matching + /// the manual `disconnect()` path (which also clears the flag). Without this + /// the flag stays `true` after an engine drop and reconnection is skipped. + private func handleEngineDisconnect() async { + connected = false + notifyConnectionState(.disconnected) + } + // MARK: - Event routing (glue kept on top of the engine) private func routeEvent(_ event: RelayEvent) { @@ -572,6 +691,7 @@ actor HostedParticipantCore { } private func loadInvocation(actionName: String, invocationId: String) async throws -> Relaycast.ActionInvocation { + let engine = try engine() do { return try await engine.actions.getInvocation(name: actionName, invocationID: invocationId) } catch let error as Relaycast.RelayError { @@ -580,6 +700,7 @@ actor HostedParticipantCore { } private func completeInvocation(actionName: String, invocationId: String, output: [String: Relaycast.JSONValue]) async throws { + let engine = try engine() do { _ = try await engine.actions.completeInvocation( name: actionName, @@ -592,6 +713,7 @@ actor HostedParticipantCore { } private func completeInvocation(actionName: String, invocationId: String, error: String) async throws { + let engine = try engine() do { _ = try await engine.actions.completeInvocation( name: actionName, From f9b83aba6ba8ec3e3619df261739d89e3e2e5e32 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 15:48:05 -0400 Subject: [PATCH 4/7] Depend on relaycast v4.2.0 (drop throwaway revision pin) relaycast#208 (root Package.swift) is merged and published as v4.2.0, so switch the SwiftPM dependency from the pinned `swift-root-package` revision to `from: "4.2.0"`. swift build + swift test pass resolving the published tag (70 tests). Updated the README note accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/Package.swift | 15 ++++----------- packages/sdk-swift/README.md | 9 +++------ 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/sdk-swift/Package.swift b/packages/sdk-swift/Package.swift index bb5c71990..914cce95e 100644 --- a/packages/sdk-swift/Package.swift +++ b/packages/sdk-swift/Package.swift @@ -7,16 +7,9 @@ import PackageDescription // relaycast's Swift SDK lives in a subdirectory of the relaycast monorepo // (`packages/sdk-swift`), so it cannot be consumed as a plain git-URL SwiftPM // dependency on its own (git dependencies require `Package.swift` at the -// repository root). A root-level manifest that vends the `Relaycast` library is -// added in the relaycast monorepo (see AgentWorkforce/relaycast#208); this -// package depends on it via that repository's git URL. -// -// The dependency is pinned to a specific revision of the `swift-root-package` -// branch until that PR is merged and the monorepo is re-tagged. Once a tag that -// includes the root manifest is published, replace `revision:` with -// `from: "x.y.z"`: -// -// .package(url: "https://github.com/AgentWorkforce/relaycast.git", from: "4.1.7") +// repository root). A root-level manifest vending the `Relaycast` library was +// added in the relaycast monorepo (AgentWorkforce/relaycast#208) and published +// as v4.2.0; this package depends on it via that repository's git URL. // let package = Package( name: "AgentRelaySDK", @@ -33,7 +26,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/AgentWorkforce/relaycast.git", - revision: "24c9140824518bf371a6c09f8be1f2a298efaf56" + from: "4.2.0" ) ], targets: [ diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index f7d85ad31..b98e8eaee 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -39,14 +39,11 @@ added to the relaycast monorepo (see and this package depends on it via that repository's git URL: ```swift -.package(url: "https://github.com/AgentWorkforce/relaycast.git", revision: "") +.package(url: "https://github.com/AgentWorkforce/relaycast.git", from: "4.2.0") ``` -The dependency is pinned to a revision of the `swift-root-package` branch until -that PR is merged and the relaycast monorepo is re-tagged. Once a tag that -includes the root manifest is published, replace `revision:` with a version -requirement, e.g. -`.package(url: "https://github.com/AgentWorkforce/relaycast.git", from: "4.1.7")`. +The root manifest landed in relaycast#208 and was published as v4.2.0, so this +package depends on it by version. ## Quick start From 5e5f33489da63afd62e3e0507e8cd2aaac0051f0 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 15:48:39 -0400 Subject: [PATCH 5/7] Update Package.resolved to relaycast v4.2.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/Package.resolved | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk-swift/Package.resolved b/packages/sdk-swift/Package.resolved index cd9a06e4b..47cdadbdf 100644 --- a/packages/sdk-swift/Package.resolved +++ b/packages/sdk-swift/Package.resolved @@ -5,7 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AgentWorkforce/relaycast.git", "state" : { - "revision" : "24c9140824518bf371a6c09f8be1f2a298efaf56" + "revision" : "21830a01a62defcff261d2de59843b6afff7f534", + "version" : "4.2.0" } } ], From 8c891c719df8d7e4f2e36551370813277b4d6672 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 23 Jun 2026 19:14:21 -0400 Subject: [PATCH 6/7] fix(sdk-swift): default base URL to cast.agentrelay.com Default to cast.agentrelay.com (canonical host). gateway/api.relaycast.dev now only persist in already-shipped versions, served by the legacy-router strangler. Existing pre-cutover users with old-DB workspaces who rely on the default should set RELAY_BASE_URL or migrate. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift | 2 +- .../sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index d579fe01a..b2b6ab0f5 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -58,7 +58,7 @@ public final class AgentRelay: @unchecked Sendable { if let baseURL { return baseURL } - return URL(string: "https://gateway.relaycast.dev")! + return URL(string: "https://cast.agentrelay.com")! } } diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 64301e5f6..1c1cd08b3 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -15,7 +15,7 @@ final class HostedParticipantSDKTests: XCTestCase { func testClientInitDefaultsToHostedGateway() { let client = AgentRelayClient(apiKey: "rk_test_key") XCTAssertEqual(client.workspaceKey, "rk_test_key") - XCTAssertEqual(client.baseURL.absoluteString, "https://gateway.relaycast.dev") + XCTAssertEqual(client.baseURL.absoluteString, "https://cast.agentrelay.com") } func testClientHonoursExplicitBaseURL() { From a8b8f3b9ca94cf1ea0ccf9b2a9267baa8cb8c7f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 Jun 2026 20:48:58 +0000 Subject: [PATCH 7/7] style: auto-format with Prettier --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eedbf8ffe..6881b89b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,7 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Retry inbound webhook auth from local broker -- Escape glob in @agent-relay/* to satisfy prettier +- Escape glob in @agent-relay/\* to satisfy prettier ## [9.1.0] - 2026-06-24