Skip to content

Add Swift SDK and OpenAPI sync guard#173

Merged
willwashburn merged 6 commits into
mainfrom
feat/swift-sdk-openapi-sync
Jun 10, 2026
Merged

Add Swift SDK and OpenAPI sync guard#173
willwashburn merged 6 commits into
mainfrom
feat/swift-sdk-openapi-sync

Conversation

@willwashburn

Copy link
Copy Markdown
Member

Summary

  • add a new SwiftPM SDK under packages/sdk-swift with REST, WebSocket, RelayCast, AgentClient, models, docs, changelog, and tests
  • add a shared SDK/OpenAPI route sync test so SDKs cannot drift from documented API paths unnoticed
  • fix route drift found by the guard in Rust and Swift console stats helpers
  • add Python AgentClient.me()/AsyncAgentClient.me() parity for /v1/agent

Verification

  • npm run -w @relaycast/types test
  • cargo test --manifest-path packages/sdk-rust/Cargo.toml
  • swift test
  • .venv/bin/python -m pytest (from packages/sdk-python)

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@codeant-ai

codeant-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@willwashburn, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 58 minutes and 58 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1844067b-bf10-4f21-af74-b12699f34266

📥 Commits

Reviewing files that changed from the base of the PR and between f7ddf37 and 789f6da.

📒 Files selected for processing (2)
  • memory/workspace/.relay/state.json
  • packages/sdk-rust/src/relay.rs
📝 Walkthrough

Walkthrough

This PR introduces a complete Swift SDK for Relaycast built on SwiftPM with HTTP and WebSocket support, adds a Vitest test that enforces OpenAPI/SDK route synchronization across all SDKs, extends Python Agent clients with identity methods, and corrects a Rust statistics endpoint.

Changes

Swift SDK and cross-SDK synchronization

Layer / File(s) Summary
OpenAPI/SDK route synchronization guard
packages/types/src/__tests__/sdk-openapi-sync.test.ts
New Vitest suite that parses openapi.yaml paths, scans SDK source for /v1/... route literals, and asserts no SDK references undocumented routes, all OpenAPI routes are SDK-covered or allowlisted, and core agent messaging routes are present in every SDK.
Python and Rust SDK parity
packages/sdk-python/src/relay_sdk/agent.py, packages/sdk-python/tests/test_agent.py, packages/sdk-rust/src/relay.rs
Python AgentClient and AsyncAgentClient gain me() methods to fetch /v1/agent with full test coverage; Rust RelayCast::stats endpoint corrected from /v1/workspace/stats to /v1/console/stats.
Swift package structure and configuration
packages/sdk-swift/Package.swift, packages/sdk-swift/Sources/Relaycast/Version.swift, packages/sdk-swift/.gitignore, packages/sdk-swift/CHANGELOG.md, packages/sdk-swift/README.md, README.md
SwiftPM package manifest defines library product, test target, and platform minimums (macOS 12+, iOS 15+, tvOS 15+, watchOS 8+); version constant 0.1.0 and origin metadata; build artifact ignores; initial changelog; comprehensive README with install/quick-start/API shape/self-hosting; root README documentation and packages table entry.
Swift data models, encoding, and error handling
packages/sdk-swift/Sources/Relaycast/Casing.swift, packages/sdk-swift/Sources/Relaycast/JSONValue.swift, packages/sdk-swift/Sources/Relaycast/Errors.swift, packages/sdk-swift/Sources/Relaycast/Harness.swift, packages/sdk-swift/Sources/Relaycast/Models.swift
Snake_case/camelCase conversion, path component encoding, idempotency header generation, harness/distinct-ID sanitization with regex/length limits; JSONValue enum with literal expressibles; RelayError with computed code/status/retryable properties; comprehensive Codable models for API responses, workspaces, agents, channels, messages, DMs, deliveries, files, webhooks, subscriptions, actions, directory, A2A, certification, console, and WebSocket events (including dynamic JSON key handling for MessageBlock and WsEvent).
Swift HTTP client with retry and backoff
packages/sdk-swift/Sources/Relaycast/HttpClient.swift
RetryPolicy with configurable max retries, backoff multiplier, and retryable status set; ClientOptions and RequestOptions for metadata and per-request headers; HttpClient with typed async methods (get, post, patch, put, delete) that build requests with authorization, SDK headers, optional idempotency, and JSON bodies; generic request method that executes, validates HTTP responses, unwraps ApiResponse<T>, handles 204/EmptyResponse, retries on configured statuses with Retry-After parsing and exponential backoff with optional jitter.
Swift WebSocket client with reconnection and event dispatch
packages/sdk-swift/Sources/Relaycast/WsClient.swift
WsClientOptions and RelaycastEventHandlers typed registration API; WsClient manages URLSessionWebSocketTask, three async loops (receive, ping, reconnect), channel subscriptions, lifecycle (open/close/error/reconnecting/permanently_disconnected), incoming message decode and dispatch to registered handlers, exponential backoff reconnection capped by delay/attempts, outbound ping/message encoding via relaycast encoder.
Swift RelayCast main client and workspace services
packages/sdk-swift/Sources/Relaycast/RelayCast.swift
RelayCastOptions/WebSocketOptions/bootstrap options; main RelayCast client that wires HTTP/WebSocket, exposes connect/disconnect, workspace creation/lookup/ensure, agent registration with "register-or-rotate" conflict handling and token rotation, identity resolution, directory/routing/skills/A2A/console operations; delegating services: RelayWorkspaceService, RelayWorkspaceStreamService, RelaySystemPromptService, RelayChannelsService, RelayMessagesService, RelayAgentsService, RelayAgentEventsService, RelayWebhooksService, RelaySubscriptionsService, RelayActionsService, RelayCertifyService, RelayConsoleService.
Swift AgentClient and agent-scoped services
packages/sdk-swift/Sources/Relaycast/AgentClient.swift
AgentClientOptions with heartbeat interval and WebSocket options; Subscription and EnsureChannelOutcome value types; AgentPresenceService for online/offline/heartbeat; main AgentClient that manages presence, WebSocket subscriptions and event filtering, auto-heartbeat background task, broad async API (profile, messaging, replies, DMs, reactions, search, inbox, read/reader/status, delivery ack/fail/defer); delegating services: AgentDMService (conversations, group DM, participants), AgentChannelsService (create/list/get/join/leave/topic/archive/invite/members/mute with 409-aware ensureJoinedChannel), AgentActionsService (invoke/complete/fetch), AgentFilesService (upload/complete/get/delete/list).
Swift tests and trajectory records
packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift, .agentworkforce/trajectories/completed/2026-06/traj_4284v61ddbby/..., .agentworkforce/trajectories/completed/2026-06/traj_kocqa1xz8wrx/..., memory/workspace/.relay/state.json
Swift XCTest suite validates HTTP header encoding (snake_case JSON, origin/harness/distinct-ID headers), query parameter camelCase→snake_case conversion, idempotency key propagation, API error mapping, retry behavior on 429/5xx, agent registration and identity resolution, agent message sending with channel name normalization; mock infrastructure with MockURLProtocol and ephemeral URLSession; trajectory metadata JSON files document completed "Create Swift SDK for Relaycast" (88% confidence) and "Add SDK OpenAPI sync guard" work; workspace state file for relay synchronization.

Sequence Diagram

sequenceDiagram
  participant App as Application
  participant RC as RelayCast
  participant HC as HttpClient
  participant WC as WsClient
  participant API as Relaycast API
  App->>RC: registerAgent(name)
  RC->>HC: POST /v1/agents
  HC->>API: HTTP + auth + headers
  API-->>HC: ApiResponse{token}
  HC-->>RC: Agent token
  RC-->>App: Agent created
  App->>RC: asAgent(token)
  RC->>RC: AgentClient(HttpClient)
  RC-->>App: AgentClient
  App->>WC: connect()
  WC->>API: WebSocket /v1/ws
  API-->>WC: open + subscribe prompt
  WC-->>App: lifecycle: connected
  App->>WC: on(messageCreated, handler)
  App->>HC: POST /v1/channels/general/messages
  HC->>API: send message
  API-->>WC: WsEvent{messageCreated}
  WC-->>App: invoke messageCreated handler
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • AgentWorkforce/relaycast#148: Adds/removes MCP/A2A/console/directory/routing endpoints in openapi.yaml, directly coupled to the new Vitest sync guard that parses those paths and validates SDK coverage.
  • AgentWorkforce/relaycast#151: Changes commands endpoints to actions in openapi.yaml and SDKs, which the new Vitest route-sync guard test validates across all SDK implementations.
  • AgentWorkforce/relaycast#155: Adds durable delivery routes (/v1/deliveries and /ack|/fail|/defer variants), which the new Vitest OpenAPI↔SDK route sync guard would directly validate.

Suggested labels

size:XXL

Poem

🐰 A Swift SDK blooms for Relaycast today,
With HTTP, WebSocket, and models on display!
The routes stay in sync 'cross Python, Rust, and more,
While agents now know themselves—what's not to explore? ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add Swift SDK and OpenAPI sync guard' clearly and concisely summarizes the main changes: introducing a Swift SDK and implementing an OpenAPI route synchronization guard.
Description check ✅ Passed The description provides relevant details about the changeset including the Swift SDK addition, OpenAPI sync test, route drift fixes, and Python method parity additions, all directly related to the modifications present.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/swift-sdk-openapi-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeant-ai

codeant-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 393560b901

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

public struct UploadResponse: Codable, Equatable, Sendable {
public let fileId: String

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the file response schema returned by the API

When me.files.upload(...) hits the real engine, /v1/files/upload returns data: { id, upload_url, expires_at } (and the get/list file endpoints use id, size_bytes, and download_url). With the decoder's snake_case conversion, id does not populate this required fileId, so uploads fail to decode successfully; the adjacent FileInfo model has the same id/size/url mismatch for get/list responses.

Useful? React with 👍 / 👎.

socket = task
}
task.resume()
reconnectAttempt = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop resetting reconnect attempts before connection success

If the WebSocket URL is unreachable or the handshake is rejected, every retry re-enters openSocket() and resets reconnectAttempt to 0 before the receive loop observes the failure. In that failure mode scheduleReconnect always increments from zero again, so maxReconnectAttempts is never reached and permanentlyDisconnected never fires.

Useful? React with 👍 / 👎.

@codeant-ai

codeant-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="memory/workspace/.relay/state.json">

<violation number="1" location="memory/workspace/.relay/state.json:1">
P1: Runtime-generated state file `memory/workspace/.relay/state.json` committed to source control. This will cause churn on every reconciliation and contains machine-local paths. Add `.relay/` to `.gitignore` alongside the equivalent `.agent-relay/` entry.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread memory/workspace/.relay/state.json Outdated
@@ -0,0 +1 @@
{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-06T17:20:55.827168785Z","lastSuccessfulReconcileAt":"2026-06-06T17:20:55.827168785Z","staleAfter":"2026-06-06T17:21:05.827168785Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":10},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"}} No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Runtime-generated state file memory/workspace/.relay/state.json committed to source control. This will cause churn on every reconciliation and contains machine-local paths. Add .relay/ to .gitignore alongside the equivalent .agent-relay/ entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At memory/workspace/.relay/state.json, line 1:

<comment>Runtime-generated state file `memory/workspace/.relay/state.json` committed to source control. This will cause churn on every reconciliation and contains machine-local paths. Add `.relay/` to `.gitignore` alongside the equivalent `.agent-relay/` entry.</comment>

<file context>
@@ -0,0 +1 @@
+{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-06T17:20:55.827168785Z","lastSuccessfulReconcileAt":"2026-06-06T17:20:55.827168785Z","staleAfter":"2026-06-06T17:21:05.827168785Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":10},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"}}
\ No newline at end of file
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (1)
packages/sdk-swift/Sources/Relaycast/RelayCast.swift (1)

690-721: ⚡ Quick win

Bootstrap requests bypass configured transport/session behavior.

workspaceRequest always uses URLSession.shared, so createWorkspace/lookupWorkspace/ensureWorkspace cannot use caller-provided session configuration (timeouts/protocol hooks), unlike the rest of the client stack.

Suggested refactor
 public static func createWorkspace(
     _ name: String,
-    options: WorkspaceBootstrapOptions = WorkspaceBootstrapOptions()
+    options: WorkspaceBootstrapOptions = WorkspaceBootstrapOptions(),
+    session: URLSession = .shared
 ) async throws -> CreateWorkspaceResponse {
     let (workspace, _) = try await workspaceRequest(
         method: "POST",
         path: "/v1/workspaces",
         body: CreateWorkspaceRequest(name: name),
-        options: options
+        options: options,
+        session: session
     ) as (CreateWorkspaceResponse, Int)
@@
 static func workspaceRequest<T: Decodable, Body: Encodable>(
     method: String,
     path: String,
     body: Body?,
-    options: WorkspaceBootstrapOptions
+    options: WorkspaceBootstrapOptions,
+    session: URLSession = .shared
 ) async throws -> (T, Int) {
@@
-    let (data, response) = try await URLSession.shared.data(for: request)
+    let (data, response) = try await session.data(for: request)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/RelayCast.swift` around lines 690 - 721,
workspaceRequest currently hardcodes URLSession.shared which prevents
createWorkspace/lookupWorkspace/ensureWorkspace from honoring caller-provided
session configuration; update workspaceRequest to take or use a URLSession from
WorkspaceBootstrapOptions (e.g., options.session or options.urlSession) and use
that session to perform the request (falling back to URLSession.shared if nil),
then update any call sites (createWorkspace, lookupWorkspace, ensureWorkspace)
to pass their configured session via WorkspaceBootstrapOptions so
timeouts/protocol hooks are respected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@memory/workspace/.relay/state.json`:
- Line 1: The committed runtime workspace state file state.json contains
environment-specific keys (e.g., "localRoot", "lastReconcileAt",
"pendingWriteback", "counters", "circuit") and must be removed from version
control; delete or git-remove the tracked file (state.json) from the repo, add
its containing path (the workspace state file or directory) to .gitignore to
prevent re-committing, and commit the removal; if you need to preserve a
template, add a state.json.example with sanitized/static defaults instead of the
real runtime file.

In `@packages/sdk-swift/Sources/Relaycast/AgentClient.swift`:
- Around line 130-133: AgentClient's init accepts a session but doesn't store or
forward it to WsClient; add a stored property (e.g., private let session:
URLSession) set from the init(session:) and update webSocket() (and the other
WsClient-creating code around the 323-338 area) to construct WsClient with that
stored session instead of using .shared so the injected URLSession is used for
both HTTP and WebSocket clients.
- Around line 144-163: connect() currently registers new WsClient lifecycle
handlers on every call (via webSocket()), causing duplicate markOnline(),
startAutoHeartbeat(), stopAutoHeartbeat(), and resubscribe calls because
subscribe(_:onMessage:) calls connect(). Fix by moving handler registration to
the socket creation path (webSocket() or initializer) so handlers are attached
once, or store and reuse the single WsClient instance and its handler tokens;
alternatively keep the returned handler tokens from socket.on.connected /
disconnected / permanentlyDisconnected and cancel them before re-registering.
Update references in AgentClient to ensure presence.markOnline(),
startAutoHeartbeat(), stopAutoHeartbeat(), and ws?.subscribe(...) are only
invoked from the single registered handlers and that subscribe(_:onMessage:)
does not re-register handlers.
- Around line 439-490: Channel paths are built from raw names causing mismatches
when callers include a leading '#'; update all channel-related methods to
normalize names by calling stripHash(...) before inserting into the path. For
functions get(_:), join(_:), leave(_:), setTopic(_:topic:), archive(_:),
invite(channel:agent:), members(_:), update(_:data:), mute(_:), and unmute(_:),
trim the leading '#' from the incoming name/channel and use that stripped value
in percentEncodePathComponent(...) and in request bodies where applicable. In
ensureJoinedChannel(_:), stripHash on request.name before calling create(...)
and join(...) and use the stripped name in the returned EnsureChannelOutcome so
the whole flow consistently uses normalized channel names.
- Around line 73-78: The public service wrapper classes (AgentPresenceService,
AgentDMService, AgentChannelsService, AgentActionsService, AgentFilesService)
currently store private unowned let agent: AgentClient which can dereference a
dangling pointer if AgentClient is deallocated; change these to hold either a
strong reference (retain AgentClient) or a weak reference (private weak var
agent: AgentClient?) and add a safe error path in each wrapper method (e.g.,
guard let agent = agent else { return / throw / call a completion with an error
}) so calls to presence, dms, channels, actions, and files do not crash when the
parent AgentClient is released. Ensure all wrapper initializers (init(agent:
AgentClient)) and every method that uses agent are updated to use the new
optional binding approach if you choose weak, or nothing else if you choose to
retain.

In `@packages/sdk-swift/Sources/Relaycast/Casing.swift`:
- Around line 42-49: The idempotencyHeaders(_ key: String?) function trims the
input only for the emptiness check but then uses the original untrimmed key when
building headers, causing mismatched wire keys; fix it by computing a
trimmedValue (e.g., let trimmed = key?.trimmingCharacters(in:
.whitespacesAndNewlines)) and use trimmed (if non-empty) or a generated UUID
string as the header value, avoid force-unwrapping key, and ensure both
"Idempotency-Key" and "X-Idempotency-Key" use that same trimmed-derived value.

In `@packages/sdk-swift/Sources/Relaycast/HttpClient.swift`:
- Around line 180-185: The request retry logic currently retries on configured
status codes and transport errors unconditionally; change it so retries are only
performed for safe HTTP methods (e.g., GET, HEAD, OPTIONS) or, for non-safe
methods (POST/PUT/PATCH/DELETE), only when RequestOptions.idempotencyKey is
present; update the block around retryPolicy.retryOn and attempt (and the same
logic at the second occurrence noted) to check request.method (or
RequestOptions) before computing retryDelayMilliseconds and calling
sleep(milliseconds:) so writes without an idempotency key are not retried.

In `@packages/sdk-swift/Sources/Relaycast/JSONValue.swift`:
- Around line 12-30: JSONValue.init(from:) currently decodes numbers as Int then
Double which can lose precision for large integers; update the decoder to
attempt an exact integer/number representation before Double by trying Int64 (or
UInt64 where appropriate) or Decimal, or add a dedicated case (e.g., .int64 /
.decimal / .rawNumberString) to the JSONValue enum to preserve the original
numeric value; modify the init(from:) to try container.decode(Int64.self)
(and/or Decimal.self) before Double and set the matching enum case (and update
encoding/other consumers to handle the new case).

In `@packages/sdk-swift/Sources/Relaycast/Models.swift`:
- Around line 435-460: MessageBlock (and the analogous WsEvent) currently
encodes the discriminator "type" separately but then blindly writes every key
from data/payload, allowing a user-supplied "type" to collide; update the
implementations so they never emit a user-supplied "type": in
MessageBlock.encode(to:) (and WsEvent.encode(to:)) filter out the key "type"
when iterating data/payload, and in the public initializer (public
init(type:data:)) either remove any "type" entry from the incoming dictionary or
validate and throw an error if it exists so callers cannot supply a conflicting
"type" key; ensure the from-decoder path still reads the discriminator "type" as
now and does not copy that key into data/payload.

In `@packages/sdk-swift/Sources/Relaycast/RelayCast.swift`:
- Around line 85-90: RelayCast mutates and reads identityHint and
workspaceIDHint from async contexts without synchronization; introduce a small
concurrency-isolated holder (e.g., a private actor RelayCastState) that owns
those two vars and exposes async getters/setters, then update resolveIdentity(),
rememberIdentity(), and resolveWorkspaceID() to call await
state.getIdentityHint()/setIdentityHint(...) and await
state.getWorkspaceID()/setWorkspaceID(...). This keeps RelayCast as `@unchecked`
Sendable while preventing races by ensuring all accesses to identityHint and
workspaceIDHint are funneled through the actor's isolated methods.

In `@packages/sdk-swift/Sources/Relaycast/WsClient.swift`:
- Around line 149-150: The openSocket() flow is marking the client as connected
and immediately resetting reconnectAttempt too early; change it so openSocket()
does not reset reconnectAttempt or call emitLifecycle(.open) right after
task.resume() — instead, reset reconnectAttempt and emit .open only once a
successful handshake/receive loop is established (move this logic into the code
path that confirms the socket is active, e.g. the start of the receive loop or
the success branch in the handshake handling), and ensure handleSocketFailure is
responsible for incrementing reconnectAttempt and scheduling retries up to
maxReconnectAttempts; update references to socket/closed/emitLifecycle and
task.resume() accordingly so connected only becomes true after the confirmed
handshake.

In `@packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift`:
- Around line 8-10: MockURLProtocol.handler is a shared static closure accessed
concurrently (set/cleared in tearDown and read in URLProtocol.startLoading());
make all access thread-safe by introducing a dedicated synchronization primitive
(e.g., a static DispatchQueue or NSLock) and use it for every read and write of
MockURLProtocol.handler. Update the places that set or clear the handler
(including tearDown) to perform writes via the synchronizer, and update
URLProtocol.startLoading() to read the handler via the same synchronizer so no
unsynchronized concurrent access can occur.

In `@packages/types/src/__tests__/sdk-openapi-sync.test.ts`:
- Around line 62-79: The current readOpenApiPaths implementation manually parses
OPENAPI_PATH by line/indentation which is brittle; replace it to parse the YAML
AST instead (e.g., use yaml/js-yaml to load fs.readFileSync(OPENAPI_PATH,
'utf8') into an object) and then iterate Object.keys(document.paths || {}) to
build the Set, calling normalizeRoutePath(`/v1${pathKey}`) for each key; ensure
you handle missing document.paths (empty Set) and preserve existing return type
so tests use the correctly parsed OpenAPI paths.

In `@README.md`:
- Around line 240-254: The quickstart is send-only; update the flow to be
realtime-first by registering a subscription/handler on the agent after
obtaining the agent instance (after RelayCast, agents.register and
relay.asAgent(...) and before sending), e.g. attach a message/event handler on
the agent returned by asAgent and call connect() so incoming messages are
handled (subscribe to relevant channel/topic or register an onMessage/onEvent
callback) prior to invoking send(), then cleanly disconnect; reference
RelayCast, RelayCastOptions, agents.register, asAgent, connect, send and
disconnect when locating where to add the subscription and handler.

---

Nitpick comments:
In `@packages/sdk-swift/Sources/Relaycast/RelayCast.swift`:
- Around line 690-721: workspaceRequest currently hardcodes URLSession.shared
which prevents createWorkspace/lookupWorkspace/ensureWorkspace from honoring
caller-provided session configuration; update workspaceRequest to take or use a
URLSession from WorkspaceBootstrapOptions (e.g., options.session or
options.urlSession) and use that session to perform the request (falling back to
URLSession.shared if nil), then update any call sites (createWorkspace,
lookupWorkspace, ensureWorkspace) to pass their configured session via
WorkspaceBootstrapOptions so timeouts/protocol hooks are respected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fb93a53c-435a-4874-9525-de1ea3485cc2

📥 Commits

Reviewing files that changed from the base of the PR and between 2ffaef0 and f7ddf37.

📒 Files selected for processing (25)
  • .agentworkforce/trajectories/completed/2026-06/traj_4284v61ddbby/summary.md
  • .agentworkforce/trajectories/completed/2026-06/traj_4284v61ddbby/trajectory.json
  • .agentworkforce/trajectories/completed/2026-06/traj_kocqa1xz8wrx/summary.md
  • .agentworkforce/trajectories/completed/2026-06/traj_kocqa1xz8wrx/trajectory.json
  • README.md
  • memory/workspace/.relay/state.json
  • packages/sdk-python/src/relay_sdk/agent.py
  • packages/sdk-python/tests/test_agent.py
  • packages/sdk-rust/src/relay.rs
  • packages/sdk-swift/.gitignore
  • packages/sdk-swift/CHANGELOG.md
  • packages/sdk-swift/Package.swift
  • packages/sdk-swift/README.md
  • packages/sdk-swift/Sources/Relaycast/AgentClient.swift
  • packages/sdk-swift/Sources/Relaycast/Casing.swift
  • packages/sdk-swift/Sources/Relaycast/Errors.swift
  • packages/sdk-swift/Sources/Relaycast/Harness.swift
  • packages/sdk-swift/Sources/Relaycast/HttpClient.swift
  • packages/sdk-swift/Sources/Relaycast/JSONValue.swift
  • packages/sdk-swift/Sources/Relaycast/Models.swift
  • packages/sdk-swift/Sources/Relaycast/RelayCast.swift
  • packages/sdk-swift/Sources/Relaycast/Version.swift
  • packages/sdk-swift/Sources/Relaycast/WsClient.swift
  • packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift
  • packages/types/src/__tests__/sdk-openapi-sync.test.ts

Comment thread memory/workspace/.relay/state.json Outdated
@@ -0,0 +1 @@
{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-06T17:17:23.826056552Z","lastSuccessfulReconcileAt":"2026-06-06T17:17:23.826056552Z","staleAfter":"2026-06-06T17:17:33.826056552Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":7},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"}} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove committed runtime workspace state from source control.

This JSON includes host-local path/state (localRoot, reconcile timestamps/counters). It is environment-specific runtime data and should not be versioned.

Suggested cleanup
- memory/workspace/.relay/state.json

Add the containing state path to the repository ignore rules so it does not reappear in future commits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@memory/workspace/.relay/state.json` at line 1, The committed runtime
workspace state file state.json contains environment-specific keys (e.g.,
"localRoot", "lastReconcileAt", "pendingWriteback", "counters", "circuit") and
must be removed from version control; delete or git-remove the tracked file
(state.json) from the repo, add its containing path (the workspace state file or
directory) to .gitignore to prevent re-committing, and commit the removal; if
you need to preserve a template, add a state.json.example with sanitized/static
defaults instead of the real runtime file.

Comment on lines +73 to +78
public final class AgentPresenceService: @unchecked Sendable {
private unowned let agent: AgentClient

init(agent: AgentClient) {
self.agent = agent
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/AgentClient.swift"

# Show the relevant ranges
sed -n '1,140p' "$FILE" | nl -ba | sed -n '60,110p'
sed -n '340,460p' "$FILE" | nl -ba
sed -n '460,560p' "$FILE" | nl -ba
sed -n '560,700p' "$FILE" | nl -ba

# Search for unowned and the public wrapper types/properties
rg -n "unowned let|unowned|AgentPresenceService|Presence|dms|channels|actions|files" "$FILE"

# Show type definitions that include these wrappers if they exist
rg -n "final class .*Service|struct .*Service|class .*Service|public (let|var) .*: .*Service" "$FILE"

Repository: AgentWorkforce/relaycast

Length of output: 111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/AgentClient.swift"

echo "---- Lines 55-110 ----"
sed -n '55,110p' "$FILE" | cat -n

echo "---- Lines 360-440 ----"
sed -n '360,440p' "$FILE" | cat -n

echo "---- Lines 460-540 ----"
sed -n '460,540p' "$FILE" | cat -n

echo "---- Lines 500-540 (more focused) ----"
sed -n '495,540p' "$FILE" | cat -n

echo "---- Lines 510-535 (more focused) ----"
sed -n '510,535p' "$FILE" | cat -n

echo "---- All unowned let occurrences ----"
rg -n "unowned let" "$FILE" || true

echo "---- All unowned occurrences ----"
rg -n "unowned" "$FILE" || true

echo "---- Wrapper service/property type names (best-effort) ----"
rg -n "AgentPresenceService|Presence|dms|channels|actions|files" "$FILE" || true

echo "---- Public API wrapper definitions ----"
rg -n "public (final class|struct|class|let|var) .*Service|public (let|var) .*: .*Service" "$FILE" || true

Repository: AgentWorkforce/relaycast

Length of output: 18903


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/sdk-swift/Sources/Relaycast/AgentClient.swift"

echo "---- Lines 110-170 (public wrapper properties) ----"
sed -n '110,170p' "$FILE" | cat -n

echo "---- Any deinit in this file ----"
rg -n "deinit" "$FILE" || true

echo "---- Any explicit invalidation/teardown in AgentClient ----"
rg -n "stop|cancel|ws|websocket|webSocket" "$FILE" | head -n 80

Repository: AgentWorkforce/relaycast

Length of output: 9144


Public service wrappers can trap after AgentClient is deallocated.

AgentClient exposes presence, dms, channels, actions, and files as public lazy wrapper instances, but each wrapper (AgentPresenceService, AgentDMService, AgentChannelsService, AgentActionsService, AgentFilesService) stores private unowned let agent: AgentClient. If a caller retains a wrapper after AgentClient is released, the next wrapper method call dereferences a dangling unowned reference and crashes. Use weak (with a proper error path) or have wrappers retain AgentClient instead.

Also applies to: 384-388, 423-427, 495-499, 518-522

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/AgentClient.swift` around lines 73 - 78,
The public service wrapper classes (AgentPresenceService, AgentDMService,
AgentChannelsService, AgentActionsService, AgentFilesService) currently store
private unowned let agent: AgentClient which can dereference a dangling pointer
if AgentClient is deallocated; change these to hold either a strong reference
(retain AgentClient) or a weak reference (private weak var agent: AgentClient?)
and add a safe error path in each wrapper method (e.g., guard let agent = agent
else { return / throw / call a completion with an error }) so calls to presence,
dms, channels, actions, and files do not crash when the parent AgentClient is
released. Ensure all wrapper initializers (init(agent: AgentClient)) and every
method that uses agent are updated to use the new optional binding approach if
you choose weak, or nothing else if you choose to retain.

Comment on lines +130 to +133
public init(token: String, baseURL: String? = nil, options: AgentClientOptions = AgentClientOptions(), session: URLSession = .shared) throws {
self.client = try HttpClient(options: ClientOptions(apiKey: token, baseURL: baseURL), session: session)
self.options = options
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The injected URLSession never reaches the WebSocket client.

The token initializer accepts a custom session, but only HttpClient receives it. webSocket() always builds WsClient with its default .shared, so tests and callers that rely on custom transport configuration only get it on the HTTP half of the SDK. Store the session on AgentClient and forward it into WsClient.

Also applies to: 323-338

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/AgentClient.swift` around lines 130 -
133, AgentClient's init accepts a session but doesn't store or forward it to
WsClient; add a stored property (e.g., private let session: URLSession) set from
the init(session:) and update webSocket() (and the other WsClient-creating code
around the 323-338 area) to construct WsClient with that stored session instead
of using .shared so the injected URLSession is used for both HTTP and WebSocket
clients.

Comment on lines +144 to +163
public func connect() {
let socket = webSocket()
socket.on.connected { [weak self] in
guard let self else { return }
Task {
try? await self.presence.markOnline()
self.startAutoHeartbeat()
let channels = Array(self.manualSubscriptions)
if !channels.isEmpty {
self.ws?.subscribe(channels)
}
}
}
socket.on.disconnected { [weak self] in
self?.stopAutoHeartbeat()
}
socket.on.permanentlyDisconnected { [weak self] _ in
self?.stopAutoHeartbeat()
}
socket.connect()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

connect() leaks duplicate lifecycle handlers.

Every connect() call registers three new handlers on the same WsClient, and subscribe(_:onMessage:) calls connect() on each subscription. After a few calls, a single reconnect fires markOnline(), startAutoHeartbeat(), and resubscription multiple times. Register these once when the socket is created, or keep and cancel the stop tokens.

Also applies to: 186-195

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/AgentClient.swift` around lines 144 -
163, connect() currently registers new WsClient lifecycle handlers on every call
(via webSocket()), causing duplicate markOnline(), startAutoHeartbeat(),
stopAutoHeartbeat(), and resubscribe calls because subscribe(_:onMessage:) calls
connect(). Fix by moving handler registration to the socket creation path
(webSocket() or initializer) so handlers are attached once, or store and reuse
the single WsClient instance and its handler tokens; alternatively keep the
returned handler tokens from socket.on.connected / disconnected /
permanentlyDisconnected and cancel them before re-registering. Update references
in AgentClient to ensure presence.markOnline(), startAutoHeartbeat(),
stopAutoHeartbeat(), and ws?.subscribe(...) are only invoked from the single
registered handlers and that subscribe(_:onMessage:) does not re-register
handlers.

Comment on lines +439 to +490
public func get(_ name: String) async throws -> ChannelWithMembers {
try await agent.client.get("/v1/channels/\(percentEncodePathComponent(name))")
}

public func join(_ name: String) async throws -> JoinChannelResponse {
try await agent.client.post("/v1/channels/\(percentEncodePathComponent(name))/join", body: EmptyRequest())
}

public func leave(_ name: String) async throws {
let _: EmptyResponse = try await agent.client.post("/v1/channels/\(percentEncodePathComponent(name))/leave", body: EmptyRequest())
}

public func setTopic(_ name: String, topic: String) async throws -> Channel {
try await agent.client.patch("/v1/channels/\(percentEncodePathComponent(name))/topic", body: TopicRequest(topic: topic))
}

public func archive(_ name: String) async throws {
_ = try await agent.client.delete("/v1/channels/\(percentEncodePathComponent(name))")
}

public func invite(channel: String, agent name: String) async throws -> InviteChannelResponse {
try await agent.client.post("/v1/channels/\(percentEncodePathComponent(channel))/invite", body: AgentNameRequest(agentName: name))
}

public func members(_ name: String) async throws -> [ChannelMemberInfo] {
try await agent.client.get("/v1/channels/\(percentEncodePathComponent(name))/members")
}

public func update(_ name: String, data: UpdateChannelRequest) async throws -> Channel {
try await agent.client.patch("/v1/channels/\(percentEncodePathComponent(name))", body: data)
}

public func mute(_ name: String) async throws {
let _: EmptyResponse = try await agent.client.post("/v1/channels/\(percentEncodePathComponent(name))/mute", body: EmptyRequest())
}

public func unmute(_ name: String) async throws {
let _: EmptyResponse = try await agent.client.post("/v1/channels/\(percentEncodePathComponent(name))/unmute", body: EmptyRequest())
}

public func ensureJoinedChannel(_ request: CreateChannelRequest) async throws -> EnsureChannelOutcome {
var created = false
do {
_ = try await create(request)
created = true
} catch let error as RelayError {
if error.statusCode != 409 {
throw error
}
}
_ = try await join(request.name)
return EnsureChannelOutcome(name: request.name, created: created, joined: true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize channel names consistently in the channel service.

send, messages, readStatus, and WebSocket subscriptions strip a leading #, but the channel-service methods build paths from the raw name. Passing #general therefore works in one part of the SDK and 404s in another. Apply stripHash before every channel path here as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/AgentClient.swift` around lines 439 -
490, Channel paths are built from raw names causing mismatches when callers
include a leading '#'; update all channel-related methods to normalize names by
calling stripHash(...) before inserting into the path. For functions get(_:),
join(_:), leave(_:), setTopic(_:topic:), archive(_:), invite(channel:agent:),
members(_:), update(_:data:), mute(_:), and unmute(_:), trim the leading '#'
from the incoming name/channel and use that stripped value in
percentEncodePathComponent(...) and in request bodies where applicable. In
ensureJoinedChannel(_:), stripHash on request.name before calling create(...)
and join(...) and use the stripped name in the returned EnsureChannelOutcome so
the whole flow consistently uses normalized channel names.

Comment on lines +85 to +90
public final class RelayCast: @unchecked Sendable {
public let client: HttpClient
private let ws: WsClient
private var identityHint: (agentID: String, name: String)?
private var workspaceIDHint: String?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/RelayCast.swift"

echo "== File existence =="
ls -la "$FILE"

echo
echo "== Class declaration (near lines 70-110) =="
nl -ba "$FILE" | sed -n '70,115p'

echo
echo "== Usages of identityHint and workspaceIDHint =="
rg -n "identityHint|workspaceIDHint" "$FILE"

echo
echo "== Context around first read/mutation sites (lines ~220-260, ~330-380) =="
nl -ba "$FILE" | sed -n '210,270p'
nl -ba "$FILE" | sed -n '320,390p'

echo
echo "== Context around workspaceRequest/bootstrap (lines ~670-760) =="
nl -ba "$FILE" | sed -n '660,760p'

Repository: AgentWorkforce/relaycast

Length of output: 299


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/RelayCast.swift"

echo "== Class declaration (lines ~70-120) =="
cat -n "$FILE" | sed -n '70,120p'

echo
echo "== Usages of identityHint and workspaceIDHint =="
rg -n "identityHint|workspaceIDHint" "$FILE"

echo
echo "== Context around read/mutation sites (lines ~200-270, ~320-400) =="
cat -n "$FILE" | sed -n '200,280p'
echo "----"
cat -n "$FILE" | sed -n '320,420p'

echo
echo "== Context around workspaceRequest/bootstrap (lines ~650-780) =="
cat -n "$FILE" | sed -n '650,780p'

Repository: AgentWorkforce/relaycast

Length of output: 16280


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/sdk-swift/Sources/Relaycast/RelayCast.swift"
cat -n "$FILE" | sed -n '1,90p'

Repository: AgentWorkforce/relaycast

Length of output: 3566


Synchronize mutable state used by @unchecked Sendable RelayCast.

identityHint and workspaceIDHint are mutated/read across async APIs (resolveIdentity() / resolveWorkspaceID() and rememberIdentity()) with no isolation, so concurrent calls can race and produce inconsistent cached identity/workspace values (see resolveIdentity() ~235-240 and rememberIdentity()/resolveWorkspaceID() ~343-354).

Suggested fix
 public final class RelayCast: `@unchecked` Sendable {
     public let client: HttpClient
     private let ws: WsClient
+    private let stateLock = NSLock()
     private var identityHint: (agentID: String, name: String)?
     private var workspaceIDHint: String?
@@
     public func resolveIdentity() async throws -> ResolvedIdentity {
-        guard let identityHint else {
+        stateLock.lock()
+        let hint = identityHint
+        stateLock.unlock()
+        guard let identityHint = hint else {
             throw RelayError.api(code: "not_found", message: "No identity is available. Register or rotate an agent first.", statusCode: 404, retryable: false)
         }
         return ResolvedIdentity(agentId: identityHint.agentID, name: identityHint.name, workspaceId: try await resolveWorkspaceID())
     }
@@
     func rememberIdentity(agentID: String, name: String) {
+        stateLock.lock()
         identityHint = (agentID, name)
+        stateLock.unlock()
     }

     private func resolveWorkspaceID() async throws -> String {
-        if let workspaceIDHint {
-            return workspaceIDHint
-        }
+        stateLock.lock()
+        if let cached = workspaceIDHint {
+            stateLock.unlock()
+            return cached
+        }
+        stateLock.unlock()
         let workspace = try await workspace.info()
+        stateLock.lock()
         workspaceIDHint = workspace.id
+        stateLock.unlock()
         return workspace.id
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/RelayCast.swift` around lines 85 - 90,
RelayCast mutates and reads identityHint and workspaceIDHint from async contexts
without synchronization; introduce a small concurrency-isolated holder (e.g., a
private actor RelayCastState) that owns those two vars and exposes async
getters/setters, then update resolveIdentity(), rememberIdentity(), and
resolveWorkspaceID() to call await state.getIdentityHint()/setIdentityHint(...)
and await state.getWorkspaceID()/setWorkspaceID(...). This keeps RelayCast as
`@unchecked` Sendable while preventing races by ensuring all accesses to
identityHint and workspaceIDHint are funneled through the actor's isolated
methods.

Comment on lines +149 to +150
public var connected: Bool {
lock.withLock { socket != nil && !closed }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/WsClient.swift"

echo "== File check =="
ls -l "$FILE"

echo "== connected property =="
nl -ba "$FILE" | sed -n '130,190p'

echo "== openSocket + reconnectAttempt logic =="
nl -ba "$FILE" | sed -n '190,270p'

echo "== later reconnect/emit logic (per comment: 314-335) =="
nl -ba "$FILE" | sed -n '290,360p'

echo "== Find key symbols =="
rg -n "openSocket|resume\\(|reconnectAttempt|maxReconnectAttempts|\\.open\\b|closed\\b|connected\\b|URLSessionWebSocketTask" "$FILE"

Repository: AgentWorkforce/relaycast

Length of output: 274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/sdk-swift/Sources/Relaycast/WsClient.swift"

echo "== connected property (lines ~130-190) =="
awk 'NR>=130 && NR<=190 {printf "%d:%s\n", NR, $0}' "$FILE"

echo "== openSocket + reconnectAttempt logic (lines ~190-270) =="
awk 'NR>=190 && NR<=270 {printf "%d:%s\n", NR, $0}' "$FILE"

echo "== later reconnect/emit logic (lines ~290-360) =="
awk 'NR>=290 && NR<=360 {printf "%d:%s\n", NR, $0}' "$FILE"

echo "== Find key symbols =="
rg -n "openSocket|resume\(|reconnectAttempt|maxReconnectAttempts|\.open\b|closed\b|connected\b|URLSessionWebSocketTask" "$FILE"

Repository: AgentWorkforce/relaycast

Length of output: 9414


Fix false “open/connected” state and reconnect counter reset in WsClient.openSocket().

openSocket() sets socket, calls task.resume(), immediately resets reconnectAttempt = 0, and emits emitLifecycle(.open) (so connected becomes true via socket != nil && !closed). If the WebSocket handshake fails before the receive loop establishes anything, handleSocketFailure schedules a reconnect—but openSocket() resets the counter on every retry, so reconnectAttempt never accumulates and maxReconnectAttempts is never reached.

Also applies to: 229-235, 314-335

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Sources/Relaycast/WsClient.swift` around lines 149 - 150,
The openSocket() flow is marking the client as connected and immediately
resetting reconnectAttempt too early; change it so openSocket() does not reset
reconnectAttempt or call emitLifecycle(.open) right after task.resume() —
instead, reset reconnectAttempt and emit .open only once a successful
handshake/receive loop is established (move this logic into the code path that
confirms the socket is active, e.g. the start of the receive loop or the success
branch in the handshake handling), and ensure handleSocketFailure is responsible
for incrementing reconnectAttempt and scheduling retries up to
maxReconnectAttempts; update references to socket/closed/emitLifecycle and
task.resume() accordingly so connected only becomes true after the confirmed
handshake.

Comment on lines +8 to +10
override func tearDown() {
MockURLProtocol.handler = nil
super.tearDown()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "---- File head (first 120 lines) ----"
nl -ba "$FILE" | head -n 140

echo "---- Handler definition + related code (lines 220-290) ----"
nl -ba "$FILE" | sed -n '220,310p'

echo "---- tearDown override (lines 1-80) ----"
nl -ba "$FILE" | sed -n '1,90p'

echo "---- Search for handler assignments/clears ----"
rg -n "MockURLProtocol\.handler|Self\.handler|handler\s*=" "$FILE"

Repository: AgentWorkforce/relaycast

Length of output: 168


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift"

echo "== File exists =="
test -f "$FILE" && echo "OK: $FILE" || (echo "Missing file: $FILE" && exit 1)

echo "== Head (1-140) with line numbers =="
python3 - <<'PY'
import itertools, pathlib
path = pathlib.Path("packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift")
lines = path.read_text().splitlines()
for i in range(0, min(140, len(lines))):
    print(f"{i+1:4d}\t{lines[i]}")
PY

echo "== Segment around handler definition (220-310) =="
python3 - <<'PY'
import pathlib
path = pathlib.Path("packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift")
lines = path.read_text().splitlines()
start, end = 220, 310
for i in range(start-1, min(end, len(lines))):
    print(f"{i+1:4d}\t{lines[i]}")
PY

echo "== tearDown override (1-90) =="
python3 - <<'PY'
import pathlib
path = pathlib.Path("packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift")
lines = path.read_text().splitlines()
for i in range(0, min(90, len(lines))):
    print(f"{i+1:4d}\t{lines[i]}")
PY

echo "== Search for handler assignments/clears/reads =="
rg -n "MockURLProtocol\.handler|Self\.handler|handler\s*=" "$FILE" || true

echo "== Search for startLoading override =="
rg -n "override\s+func\s+startLoading" "$FILE" || true

echo "== Search for tearDown override =="
rg -n "override\s+func\s+tearDown" "$FILE" || true

Repository: AgentWorkforce/relaycast

Length of output: 14252


Make MockURLProtocol.handler access thread-safe to prevent flaky parallel tests.

MockURLProtocol.handler is a shared mutable static closure that’s set/cleared from tests and tearDown (lines 8-10) while URLProtocol.startLoading() reads it without any synchronization (lines 243-266), so parallel/overlapping requests can interleave and yield underscores/non-deterministic failures. Use a lock/serial queue (or per-test isolated handler state) for all reads/writes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk-swift/Tests/RelaycastTests/RelaycastTests.swift` around lines 8
- 10, MockURLProtocol.handler is a shared static closure accessed concurrently
(set/cleared in tearDown and read in URLProtocol.startLoading()); make all
access thread-safe by introducing a dedicated synchronization primitive (e.g., a
static DispatchQueue or NSLock) and use it for every read and write of
MockURLProtocol.handler. Update the places that set or clear the handler
(including tearDown) to perform writes via the synchronizer, and update
URLProtocol.startLoading() to read the handler via the same synchronizer so no
unsynchronized concurrent access can occur.

Comment on lines +62 to +79
function readOpenApiPaths(): Set<string> {
const lines = fs.readFileSync(OPENAPI_PATH, 'utf8').split(/\r?\n/);
const paths = new Set<string>();
let inPaths = false;

for (const line of lines) {
if (line.startsWith('paths:')) {
inPaths = true;
continue;
}
if (!inPaths) continue;
if (/^[A-Za-z][^:]*:/.test(line)) break;

const match = line.match(/^ (\/[^:]+):\s*$/);
if (match) {
paths.add(normalizeRoutePath(`/v1${match[1]}`));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Brittle OpenAPI parsing can create false route-sync results

Line 63–79 manually parses YAML by indentation/regex, so valid openapi.yaml formatting variations (quoted keys, indentation/style changes, comments around sections) can cause openApiPaths to be incomplete and make this guard fail or pass for the wrong reason. Please parse the YAML structure instead of scanning lines.

Proposed fix
+import { parse } from 'yaml';
+
 function readOpenApiPaths(): Set<string> {
-  const lines = fs.readFileSync(OPENAPI_PATH, 'utf8').split(/\r?\n/);
-  const paths = new Set<string>();
-  let inPaths = false;
-
-  for (const line of lines) {
-    if (line.startsWith('paths:')) {
-      inPaths = true;
-      continue;
-    }
-    if (!inPaths) continue;
-    if (/^[A-Za-z][^:]*:/.test(line)) break;
-
-    const match = line.match(/^  (\/[^:]+):\s*$/);
-    if (match) {
-      paths.add(normalizeRoutePath(`/v1${match[1]}`));
-    }
-  }
-
-  return paths;
+  const doc = parse(fs.readFileSync(OPENAPI_PATH, 'utf8')) as { paths?: Record<string, unknown> };
+  const paths = new Set<string>();
+  for (const rawPath of Object.keys(doc.paths ?? {})) {
+    paths.add(normalizeRoutePath(`/v1${rawPath}`));
+  }
+  return paths;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function readOpenApiPaths(): Set<string> {
const lines = fs.readFileSync(OPENAPI_PATH, 'utf8').split(/\r?\n/);
const paths = new Set<string>();
let inPaths = false;
for (const line of lines) {
if (line.startsWith('paths:')) {
inPaths = true;
continue;
}
if (!inPaths) continue;
if (/^[A-Za-z][^:]*:/.test(line)) break;
const match = line.match(/^ (\/[^:]+):\s*$/);
if (match) {
paths.add(normalizeRoutePath(`/v1${match[1]}`));
}
}
import { parse } from 'yaml';
function readOpenApiPaths(): Set<string> {
const doc = parse(fs.readFileSync(OPENAPI_PATH, 'utf8')) as { paths?: Record<string, unknown> };
const paths = new Set<string>();
for (const rawPath of Object.keys(doc.paths ?? {})) {
paths.add(normalizeRoutePath(`/v1${rawPath}`));
}
return paths;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/types/src/__tests__/sdk-openapi-sync.test.ts` around lines 62 - 79,
The current readOpenApiPaths implementation manually parses OPENAPI_PATH by
line/indentation which is brittle; replace it to parse the YAML AST instead
(e.g., use yaml/js-yaml to load fs.readFileSync(OPENAPI_PATH, 'utf8') into an
object) and then iterate Object.keys(document.paths || {}) to build the Set,
calling normalizeRoutePath(`/v1${pathKey}`) for each key; ensure you handle
missing document.paths (empty Set) and preserve existing return type so tests
use the correctly parsed OpenAPI paths.

Comment thread README.md
Comment on lines +240 to +254
Add the SwiftPM package from this repo and import `Relaycast`:

```swift
import Relaycast

let relay = try RelayCast(options: RelayCastOptions(apiKey: "rk_live_..."))
let registered = try await relay.agents.register(
CreateAgentRequest(name: "Reviewer", type: .agent)
)

let me = try relay.asAgent(registered.token)
me.connect()
_ = try await me.send("#general", text: "Hello from Swift")
await me.disconnect()
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the Swift quickstart realtime-first, not send-only.

This snippet omits a subscription handler, which makes the onboarding flow inconsistent with the repo’s realtime-first README guidance.

As per coding guidelines, “Keep quickstart examples realtime-first, emphasizing WebSocket subscriptions and message handlers instead of polling-first flows”.

Suggested README adjustment
 let me = try relay.asAgent(registered.token)
 me.connect()
+let sub = me.subscribe(["general", "`@self`"]) { event in
+    if case .object(let message)? = event.payload["message"],
+       case .string(let text)? = message["text"] {
+        print(text)
+    }
+}
 _ = try await me.send("`#general`", text: "Hello from Swift")
+sub.unsubscribe()
 await me.disconnect()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 240 - 254, The quickstart is send-only; update the
flow to be realtime-first by registering a subscription/handler on the agent
after obtaining the agent instance (after RelayCast, agents.register and
relay.asAgent(...) and before sending), e.g. attach a message/event handler on
the agent returned by asAgent and call connect() so incoming messages are
handled (subscribe to relevant channel/topic or register an onMessage/onEvent
callback) prior to invoking send(), then cleanly disconnect; reference
RelayCast, RelayCastOptions, agents.register, asAgent, connect, send and
disconnect when locating where to add the subscription and handler.

Source: Coding guidelines

@codeant-ai

codeant-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-swift/Sources/Relaycast/RelayCast.swift">

<violation number="1" location="packages/sdk-swift/Sources/Relaycast/RelayCast.swift:85">
P1: `@unchecked Sendable` is unsafe here because mutable shared state is not concurrency-protected. Concurrent calls can race and return stale/corrupted identity/workspace hints.</violation>
</file>

<file name="packages/sdk-swift/Sources/Relaycast/Casing.swift">

<violation number="1" location="packages/sdk-swift/Sources/Relaycast/Casing.swift:44">
P2: Provided idempotency key is not normalized before use. Leading/trailing whitespace is preserved, causing mismatched idempotency keys across retries/clients.</violation>
</file>

<file name="packages/sdk-swift/Sources/Relaycast/Models.swift">

<violation number="1" location="packages/sdk-swift/Sources/Relaycast/Models.swift:23">
P1: `ActionInvocation` uses wrong wire field names, causing decode failures for action invocation endpoints. Align it to `invocationId`/`completedAt` (and related fields) used by the API schema.</violation>

<violation number="2" location="packages/sdk-swift/Sources/Relaycast/Models.swift:833">
P2: `CompleteInvocationRequest` is missing optional `durationMs`, so Swift cannot send `duration_ms` supported by the action completion API.

(Based on your team's feedback about duration_ms parity and constraints.) [FEEDBACK_USED].</violation>
</file>

<file name="packages/sdk-swift/Sources/Relaycast/WsClient.swift">

<violation number="1" location="packages/sdk-swift/Sources/Relaycast/WsClient.swift:234">
P1: Reconnect attempt counter resets too early. maxReconnectAttempts can be bypassed during repeated handshake failures.</violation>

<violation number="2" location="packages/sdk-swift/Sources/Relaycast/WsClient.swift:235">
P2: Open lifecycle event fires before connection is confirmed. Downstream logic can treat the agent as online while WebSocket setup is still failing.</violation>

<violation number="3" location="packages/sdk-swift/Sources/Relaycast/WsClient.swift:330">
P1: Reconnect cancellation is ignored, so a canceled reconnect task can still reconnect after disconnect. This can reopen sockets unexpectedly and race with explicit connect/disconnect calls.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

}
}

public final class RelayCast: @unchecked Sendable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: @unchecked Sendable is unsafe here because mutable shared state is not concurrency-protected. Concurrent calls can race and return stale/corrupted identity/workspace hints.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/RelayCast.swift, line 85:

<comment>`@unchecked Sendable` is unsafe here because mutable shared state is not concurrency-protected. Concurrent calls can race and return stale/corrupted identity/workspace hints.</comment>

<file context>
@@ -0,0 +1,737 @@
+    }
+}
+
+public final class RelayCast: @unchecked Sendable {
+    public let client: HttpClient
+    private let ws: WsClient
</file context>

// MARK: - Workspace

public struct Workspace: Codable, Equatable, Sendable {
public let id: String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: ActionInvocation uses wrong wire field names, causing decode failures for action invocation endpoints. Align it to invocationId/completedAt (and related fields) used by the API schema.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/Models.swift, line 23:

<comment>`ActionInvocation` uses wrong wire field names, causing decode failures for action invocation endpoints. Align it to `invocationId`/`completedAt` (and related fields) used by the API schema.</comment>

<file context>
@@ -0,0 +1,1516 @@
+// MARK: - Workspace
+
+public struct Workspace: Codable, Equatable, Sendable {
+    public let id: String
+    public let name: String
+    public let systemPrompt: String?
</file context>

socket = task
}
task.resume()
reconnectAttempt = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Reconnect attempt counter resets too early. maxReconnectAttempts can be bypassed during repeated handshake failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/WsClient.swift, line 234:

<comment>Reconnect attempt counter resets too early. maxReconnectAttempts can be bypassed during repeated handshake failures.</comment>

<file context>
@@ -0,0 +1,461 @@
+            socket = task
+        }
+        task.resume()
+        reconnectAttempt = 0
+        emitLifecycle(.open)
+        startReceiveLoop(task: task)
</file context>

emitLifecycle(.reconnecting(attempt: attempt))
reconnectTask = Task { [weak self] in
let delay = self?.reconnectDelayMilliseconds(attempt: attempt) ?? 1_000
try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Reconnect cancellation is ignored, so a canceled reconnect task can still reconnect after disconnect. This can reopen sockets unexpectedly and race with explicit connect/disconnect calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/WsClient.swift, line 330:

<comment>Reconnect cancellation is ignored, so a canceled reconnect task can still reconnect after disconnect. This can reopen sockets unexpectedly and race with explicit connect/disconnect calls.</comment>

<file context>
@@ -0,0 +1,461 @@
+        emitLifecycle(.reconnecting(attempt: attempt))
+        reconnectTask = Task { [weak self] in
+            let delay = self?.reconnectDelayMilliseconds(attempt: attempt) ?? 1_000
+            try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000)
+            guard let self else { return }
+            self.lock.withLock {
</file context>


func idempotencyHeaders(_ key: String?) -> [String: String] {
let value = key?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? key!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Provided idempotency key is not normalized before use. Leading/trailing whitespace is preserved, causing mismatched idempotency keys across retries/clients.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/Casing.swift, line 44:

<comment>Provided idempotency key is not normalized before use. Leading/trailing whitespace is preserved, causing mismatched idempotency keys across retries/clients.</comment>

<file context>
@@ -0,0 +1,50 @@
+
+func idempotencyHeaders(_ key: String?) -> [String: String] {
+    let value = key?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
+        ? key!
+        : UUID().uuidString
+    return [
</file context>

}

public struct FailDeliveryRequest: Codable, Equatable, Sendable {
public var error: String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: CompleteInvocationRequest is missing optional durationMs, so Swift cannot send duration_ms supported by the action completion API.

(Based on your team's feedback about duration_ms parity and constraints.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/Models.swift, line 833:

<comment>`CompleteInvocationRequest` is missing optional `durationMs`, so Swift cannot send `duration_ms` supported by the action completion API.

(Based on your team's feedback about duration_ms parity and constraints.) .</comment>

<file context>
@@ -0,0 +1,1516 @@
+}
+
+public struct FailDeliveryRequest: Codable, Equatable, Sendable {
+    public var error: String?
+    public var retryable: Bool?
+
</file context>

}
task.resume()
reconnectAttempt = 0
emitLifecycle(.open)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Open lifecycle event fires before connection is confirmed. Downstream logic can treat the agent as online while WebSocket setup is still failing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-swift/Sources/Relaycast/WsClient.swift, line 235:

<comment>Open lifecycle event fires before connection is confirmed. Downstream logic can treat the agent as online while WebSocket setup is still failing.</comment>

<file context>
@@ -0,0 +1,461 @@
+        }
+        task.resume()
+        reconnectAttempt = 0
+        emitLifecycle(.open)
+        startReceiveLoop(task: task)
+        startPingLoop()
</file context>

@codeant-ai

codeant-ai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Your free trial PR review limit of 300 PRs has been reached. Please upgrade your plan to continue using CodeAnt AI.

@willwashburn willwashburn merged commit e11b79c into main Jun 10, 2026
4 of 5 checks passed
@willwashburn willwashburn deleted the feat/swift-sdk-openapi-sync branch June 10, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant