Skip to content

feat(sdk): multiplexed subscribe, @self DM routing, stable event ids#128

Merged
khaliqgant merged 2 commits into
mainfrom
feat/sdk-subscribe-and-self-routing
May 14, 2026
Merged

feat(sdk): multiplexed subscribe, @self DM routing, stable event ids#128
khaliqgant merged 2 commits into
mainfrom
feat/sdk-subscribe-and-self-routing

Conversation

@khaliqgant

Copy link
Copy Markdown
Member

Summary

  • AgentClient.subscribe(channels, onMessage): Subscription multiplexes one websocket per agent, auto-resubscribes on reconnect, and treats @self as a DM-only filter in the high-level SDK.
  • Server resolves to: "@self" on DM send from the authenticated agentId (no client-side substitution).
  • WebSocket message events now carry a deterministic top-level UUID id derived from the message id, so clients can dedupe across reconnects.
  • RelayError distinguishes rate_limited and backpressure from generic transport failures.

Origin

Implemented as Track A of the proactive-runtime M3 swarm workflow on 2026-05-12. The worker (impl-relaycast-work-b37a7cf5) reported OWNER_DECISION: COMPLETE to the WorkflowRunner with all three workspace test suites green, then self-terminated without committing or opening a PR. The changes have been sitting uncommitted on a local rust-SDK branch since then. This PR commits that work against current main after re-verifying it locally.

Test plan

  • npm test --workspace @relaycast/types — 38 tests
  • npm test --workspace @relaycast/sdk — 294 tests
  • npm test --workspace @relaycast/server — 437 tests
  • Manual smoke: subscribe to ['general', '@self'] with two agents, verify cross-agent DMs land on @self only and channel posts land on the named channel
  • Verify event.id is stable across a reconnect+replay sequence
  • Verify RelayError.code === 'rate_limited' | 'backpressure' is surfaced when the server emits the corresponding close frames

Downstream

Tracks B–G of M3 (in cloud-runtime-run and relayfile-adapters) consume this SDK surface and likely have the same uncommitted-but-COMPLETE status — their PRs should follow this one, with a fresh @relaycast/sdk release bumped after merge.

🤖 Generated with Claude Code

- AgentClient.subscribe(channels, onMessage): Subscription multiplexes a
  single websocket per agent, auto-resubscribes on reconnect, and filters
  the @self sentinel to DM-only delivery in the SDK.
- Server resolves to: "@self" on DM send from the authenticated agentId
  rather than trusting client-side substitution.
- WebSocket message events now carry a top-level UUID id derived
  deterministically from the underlying message id, so clients can
  dedupe across reconnects.
- RelayError distinguishes rate_limited and backpressure from generic
  transport failures.

Originally implemented as Track A of the proactive-runtime M3 workflow;
the worker reported COMPLETE and ran the @relaycast/types, /sdk, and
/server vitest suites green but never opened a PR. Re-verified locally:
38 + 294 + 437 tests pass on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 63cf4d6d-6a67-40cd-b52b-f5a7c27988bb

📥 Commits

Reviewing files that changed from the base of the PR and between 38deca3 and c009615.

📒 Files selected for processing (9)
  • packages/sdk-typescript/src/__tests__/agent-ws.test.ts
  • packages/sdk-typescript/src/__tests__/errors.test.ts
  • packages/sdk-typescript/src/__tests__/event-id.test.ts
  • packages/sdk-typescript/src/agent.ts
  • packages/sdk-typescript/src/event-id.ts
  • packages/sdk-typescript/src/subscription.ts
  • packages/server/src/engine/event-id.ts
  • packages/types/src/__tests__/event-id.test.ts
  • packages/types/src/event-id.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/types/src/tests/event-id.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/sdk-typescript/src/subscription.ts
  • packages/server/src/engine/event-id.ts
  • packages/sdk-typescript/src/tests/errors.test.ts
  • packages/sdk-typescript/src/agent.ts
  • packages/types/src/event-id.ts

📝 Walkthrough

Walkthrough

This PR adds deterministic stableRelaycastEventId generation and applies it to message-like events, implements handler-based SDK subscriptions with managed WebSocket channel sync and subscription handles, supports server-side @self DM resolution, extends error codes for rate limiting/backpressure, and updates docs and tests.

Changes

Handler-based subscriptions with stable event IDs and @self DM support

Layer / File(s) Summary
Event ID schema and types
packages/types/src/event-id.ts, packages/types/src/events.ts, packages/types/src/index.ts, packages/types/src/__tests__/event-id.test.ts
Adds deterministic stableRelaycastEventId; extends message event schemas with top-level id: string (uuid) and exports event-id utilities.
Event ID implementation (SDK & server)
packages/sdk-typescript/src/event-id.ts, packages/server/src/engine/event-id.ts
Implements hashing and UUID-format helpers and exports stableRelaycastEventId in SDK and server engine packages.
Server WS transformation
packages/server/src/engine/wsTransform.ts, packages/server/src/engine/__tests__/wsTransform.test.ts
Applies stableRelaycastEventId to message.created, thread.reply, dm.received, and group_dm.received event ids and updates tests.
Server DM endpoint and OpenAPI
packages/server/src/engine/dm.ts, openapi.yaml, packages/server/src/routes/__tests__/dm.test.ts
Resolves to='@self' to the authenticated agent in sendDm; documents @self in OpenAPI and adds a test asserting passthrough to DM engine.
SDK types, Subscription interface, and API additions
packages/sdk-typescript/src/subscription.ts, packages/sdk-typescript/src/index.ts, packages/sdk-typescript/src/types.ts, packages/sdk-typescript/src/ws.ts
Adds Subscription interface and re-export, RelaycastMessageEvent type alias, and WsClient.connected getter.
AgentClient: handler-based subscriptions & post() alias
packages/sdk-typescript/src/agent.ts
Implements managed, handler-driven subscribe(channels, handler) overload returning Subscription with unsubscribe(); normalizes channels, ensures stable event IDs for handlers, tracks and syncs activeWsChannels, clears subscriptions on disconnect, and adds post() alias for send().
WS event matching & sync helpers
packages/sdk-typescript/src/agent.ts, packages/sdk-typescript/src/event-id.ts
Adds helpers to compute desired WebSocket channel sets, match incoming events to subscriptions, and incrementally sync WS subscriptions while updating activeWsChannels.
Error handling: rate limiting & backpressure
packages/sdk-typescript/src/errors.ts, packages/sdk-typescript/src/__tests__/errors.test.ts
Adds rate_limited and backpressure RelayErrorCode variants, extends RAW_CODE_MAP, maps HTTP 429 to rate_limited, and marks both as retryable; tests updated accordingly.
SDK WebSocket and event-id tests
packages/sdk-typescript/src/__tests__/agent-ws.test.ts, packages/sdk-typescript/src/__tests__/event-id.test.ts
Adds/updates tests for multiplexed subscribe(channels, handler) behavior (channels + @self), unsubscribe messages, handler rejection logging, disconnect/resend after reconnect, and stableRelaycastEventId behavior.
Feature and transformation tests
packages/sdk-typescript/src/__tests__/agent-messaging.test.ts, packages/server/src/routes/__tests__/fanout.test.ts, packages/server/src/engine/__tests__/wsTransform.test.ts
Adds post() alias test; updates fanout/fanoutToChannel expectations to include stable event id; ensures wsTransform tests reflect stable IDs.
Documentation updates
README.md
Updates quickstart and SDK examples to use client.subscribe(['general','@self'], handler) with a multiplexed websocket per agent, shows subscription handle with unsubscribe(), and removes explicit manual unsubscribe in cleanup.

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 Multiplexed websockets now hop,
Stable IDs make events drop
Handler subscriptions stay,
@self messages find their way,
Error handling won't stop!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% 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 clearly and concisely summarizes the three main changes: multiplexed subscribe functionality, @self DM routing support, and stable event IDs.
Description check ✅ Passed The description comprehensively explains the core changes (subscribe behavior, @self resolution, stable event IDs, error code distinctions) and provides context about test coverage and downstream impacts.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sdk-subscribe-and-self-routing

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

@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: 2

🧹 Nitpick comments (2)
packages/sdk-typescript/src/__tests__/errors.test.ts (1)

67-73: ⚡ Quick win

Add direct normalization tests for the two new backpressure aliases.

Line 67 covers backpressure, but queue_overloaded and workspace_stream_backpressure were also added in the map and are currently unverified in this suite.

Suggested test additions
   it('maps backpressure to backpressure', () => {
     expect(normalizeRelayErrorCode('backpressure')).toBe('backpressure');
   });
+
+  it('maps queue_overloaded to backpressure', () => {
+    expect(normalizeRelayErrorCode('queue_overloaded')).toBe('backpressure');
+  });
+
+  it('maps workspace_stream_backpressure to backpressure', () => {
+    expect(normalizeRelayErrorCode('workspace_stream_backpressure')).toBe('backpressure');
+  });
🤖 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-typescript/src/__tests__/errors.test.ts` around lines 67 - 73,
Add two direct unit tests to the errors.test.ts suite verifying that
normalizeRelayErrorCode maps 'queue_overloaded' and
'workspace_stream_backpressure' to 'backpressure' (similar to the existing test
for 'backpressure'); locate the test block that currently asserts
normalizeRelayErrorCode('backpressure') and add sibling it(...) cases for the
two new aliases to ensure they're covered by the normalizeRelayErrorCode
mapping.
packages/sdk-typescript/src/subscription.ts (1)

1-3: ⚡ Quick win

Make channels deeply readonly in the public subscription contract.

readonly channels: string[] still allows mutation via push/splice. Use a readonly array type to protect the API surface.

Proposed change
 export interface Subscription {
-  readonly channels: string[];
+  readonly channels: readonly string[];
   unsubscribe(): void;
 }
🤖 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-typescript/src/subscription.ts` around lines 1 - 3, The public
Subscription interface exposes channels as a mutable array; change its type to a
readonly array (e.g., use ReadonlyArray<string> or the readonly string[] syntax)
so consumers cannot call push/splice on the returned channels. Update the
Subscription interface declaration (the channels property) and any implementing
classes/functions that construct or return channels to satisfy the new readonly
type (convert mutable arrays to readonly when returning or cast appropriately).
Ensure the unsubscribe() signature remains unchanged.
🤖 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 `@packages/sdk-typescript/src/agent.ts`:
- Around line 265-270: The invoke function calls the async/possibly-throwing
handler onMessage without handling rejections (risking unhandled promise
rejections); update invoke (which uses matchesSubscription,
ensureRelaycastMessageEventId, and channelSet) to ensure the returned promise is
observed and failures are handled by attaching a .catch that logs/handles the
error (use this.logger.error if available or console.error) or forwards it to an
existing error handler; do not leave the promise unhandled.

In `@packages/types/src/event-id.ts`:
- Line 2: The current serialization uses parts.join(':') (assigned to variable
input) which can alias distinct tuples (e.g., ['a','b:c'] vs ['a:b','c']);
replace this unsafe join with a delimiter-safe serializer such as
JSON.stringify(parts) (or an escaping scheme) wherever parts.join(':') is used
(including the other occurrences around lines 49-59) so that each tuple maps to
a unique input string before hashing/ID generation (update the code that assigns
input and any callers that assume the old format).

---

Nitpick comments:
In `@packages/sdk-typescript/src/__tests__/errors.test.ts`:
- Around line 67-73: Add two direct unit tests to the errors.test.ts suite
verifying that normalizeRelayErrorCode maps 'queue_overloaded' and
'workspace_stream_backpressure' to 'backpressure' (similar to the existing test
for 'backpressure'); locate the test block that currently asserts
normalizeRelayErrorCode('backpressure') and add sibling it(...) cases for the
two new aliases to ensure they're covered by the normalizeRelayErrorCode
mapping.

In `@packages/sdk-typescript/src/subscription.ts`:
- Around line 1-3: The public Subscription interface exposes channels as a
mutable array; change its type to a readonly array (e.g., use
ReadonlyArray<string> or the readonly string[] syntax) so consumers cannot call
push/splice on the returned channels. Update the Subscription interface
declaration (the channels property) and any implementing classes/functions that
construct or return channels to satisfy the new readonly type (convert mutable
arrays to readonly when returning or cast appropriately). Ensure the
unsubscribe() signature remains unchanged.
🪄 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: 56e47baa-525e-43da-8399-b14443fc485a

📥 Commits

Reviewing files that changed from the base of the PR and between 207d117 and 38deca3.

📒 Files selected for processing (21)
  • README.md
  • openapi.yaml
  • packages/sdk-typescript/src/__tests__/agent-messaging.test.ts
  • packages/sdk-typescript/src/__tests__/agent-ws.test.ts
  • packages/sdk-typescript/src/__tests__/errors.test.ts
  • packages/sdk-typescript/src/agent.ts
  • packages/sdk-typescript/src/errors.ts
  • packages/sdk-typescript/src/event-id.ts
  • packages/sdk-typescript/src/index.ts
  • packages/sdk-typescript/src/subscription.ts
  • packages/sdk-typescript/src/types.ts
  • packages/sdk-typescript/src/ws.ts
  • packages/server/src/engine/__tests__/wsTransform.test.ts
  • packages/server/src/engine/dm.ts
  • packages/server/src/engine/event-id.ts
  • packages/server/src/engine/wsTransform.ts
  • packages/server/src/routes/__tests__/dm.test.ts
  • packages/server/src/routes/__tests__/fanout.test.ts
  • packages/types/src/event-id.ts
  • packages/types/src/events.ts
  • packages/types/src/index.ts

Comment thread packages/sdk-typescript/src/agent.ts
Comment thread packages/types/src/event-id.ts Outdated
@github-actions

Copy link
Copy Markdown

Preview deployed!

Environment URL
API https://pr128-api.relaycast.dev
Health https://pr128-api.relaycast.dev/health
Observer https://pr128-observer.relaycast.dev/observer

This preview shares the staging database and will be cleaned up when the PR is merged or closed.

Run E2E tests

npm run e2e -- https://pr128-api.relaycast.dev --ci

Open observer dashboard

https://pr128-observer.relaycast.dev/observer

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines 234 to 241
this.ws.disconnect();
this.ws = null;
}
this.activeWsChannels.clear();
// Always send the HTTP disconnect — it works even without a WS and
// serves as the authoritative presence update.
await this.client.post('/v1/agents/disconnect', {}).catch(() => {});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 disconnect() does not clear managedSubscriptions, causing phantom channel subscriptions with dead handlers after reconnect

After disconnect() sets this.ws = null, the managedSubscriptions map is not cleared. If the client later reconnects (via a new subscribe(channels, handler) call or explicit connect()), desiredWsChannels() at packages/sdk-typescript/src/agent.ts:536-551 still includes channels from old managed subscriptions. These channels are re-subscribed on the new WebSocket, but the old handlers were registered on the previous WsClient instance (now disconnected and nulled). Events arriving for those channels are received by the client but silently discarded because no handler on the active WsClient processes them. This wastes server-side bandwidth and causes silent event loss.

Scenario trace
  1. sub = agent.subscribe(['general'], handler) → creates WsClient A, registers handler on A
  2. await agent.disconnect() → destroys WsClient A, sets this.ws = null, but managedSubscriptions retains the entry
  3. sub2 = agent.subscribe(['dev'], handler2) → creates WsClient B, registers handler2 on B
  4. On WsClient B open, desiredWsChannels() returns {'general', 'dev'}, so both are subscribed on the wire
  5. Server sends message.created for general → arrives at WsClient B → only handler2's listeners fire → handler2 filters it out (it only watches dev) → old handler was on WsClient A → event silently dropped

(Refers to lines 225-241)

Prompt for agents
In AgentClient.disconnect(), the managedSubscriptions and manualSubscriptions maps are not cleared. This means that if the client reconnects later (via connect() or a new subscribe(channels, handler) call), desiredWsChannels() still includes channels from old managed subscriptions whose handlers are registered on the now-dead WsClient instance. Events for those channels arrive but no handler fires.

The fix should clear managed subscriptions during disconnect(). For each managed subscription, call its stop functions (to clean up handlers on the old WsClient) and then clear the map. Also clear manualSubscriptions. This ensures a clean state after disconnect.

Relevant locations:
- AgentClient.disconnect() at packages/sdk-typescript/src/agent.ts:225-241
- managedSubscriptions map at packages/sdk-typescript/src/agent.ts:143
- manualSubscriptions set at packages/sdk-typescript/src/agent.ts:142
- desiredWsChannels() at packages/sdk-typescript/src/agent.ts:536-551

Note: after this fix, users who call disconnect() and then re-subscribe would need to create new subscriptions (which is the expected pattern). The auto-reconnect path (WsClient internal reconnect) is NOT affected since it preserves the same WsClient instance.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +49 to +55
export function stableRelaycastEventId(...parts: Array<string | null | undefined>): string {
const normalized = parts
.map((part) => part?.trim())
.filter((part): part is string => typeof part === 'string' && part.length > 0);

return formatUuidFromWords(hashStringParts(['relaycast', ...normalized]));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 stableRelaycastEventId handles empty input differently in types package vs SDK/server copies

The stableRelaycastEventId function is duplicated in three packages with inconsistent empty-input handling. The packages/types/src/event-id.ts:54-56 version has a guard for empty normalized parts that returns hash(['relaycast', 'empty-event']), while the copies in packages/sdk-typescript/src/event-id.ts:54 and packages/server/src/engine/event-id.ts:54 lack this guard and would return hash(['relaycast']) for the same input. Since @relaycast/types is already a dependency of both the SDK and server, these packages could import from @relaycast/types instead of maintaining divergent copies. The inconsistency means calling stableRelaycastEventId() with no valid parts produces different UUIDs depending on which copy is used.

Suggested change
export function stableRelaycastEventId(...parts: Array<string | null | undefined>): string {
const normalized = parts
.map((part) => part?.trim())
.filter((part): part is string => typeof part === 'string' && part.length > 0);
return formatUuidFromWords(hashStringParts(['relaycast', ...normalized]));
}
export function stableRelaycastEventId(...parts: Array<string | null | undefined>): string {
const normalized = parts
.map((part) => part?.trim())
.filter((part): part is string => typeof part === 'string' && part.length > 0);
if (normalized.length === 0) {
return formatUuidFromWords(hashStringParts(['relaycast', 'empty-event']));
}
return formatUuidFromWords(hashStringParts(['relaycast', ...normalized]));
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@khaliqgant khaliqgant merged commit 1d9225d into main May 14, 2026
4 of 5 checks passed
@khaliqgant khaliqgant deleted the feat/sdk-subscribe-and-self-routing branch May 14, 2026 08:15
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