Skip to content

fix(sdk): stream relay.addListener events through registered agent clients#1226

Merged
willwashburn merged 14 commits into
mainfrom
claude/agent-relay-sdk-review-78901d
Jul 15, 2026
Merged

fix(sdk): stream relay.addListener events through registered agent clients#1226
willwashburn merged 14 commits into
mainfrom
claude/agent-relay-sdk-review-78901d

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

relay.addListener(...) on a workspace-key AgentRelay — the documented listen pattern in the README quickstart and .claude/rules/sdk.md — silently received zero events against a relaycast v5 engine. This is the root cause of SDK consumers not getting #general channel messages.

The failure chain:

  1. The listener hub connected the workspace-key client's event stream, which opens the legacy /v1/ws workspace stream.
  2. Relaycast v5 rejects workspace keys there (observer token required), so the socket 401'd into an endless reconnect loop.
  3. The failure was swallowed three times over: an empty catch {} in listeners.ts, a capability-by-existence guard in relaycast.ts, and WS auth failures becoming filtered-out pseudo-events.
  4. Meanwhile the engine correctly queued durable deliveries for the listening agent — but its implicit direct node never connected (/v1/node/ws was never opened), so they sat queued forever.

The fix routes the workspace-level listener hub through a new events fan-in (packages/sdk/src/messaging/event-fanin.ts):

  • Every agent client created by workspace.register() / workspace.reconnect() is added as a source and connected over the v5 node transport once a listener exists — in either order (listen-then-register also works; the source connects lazily when it appears).
  • The engine sends one deliver frame per recipient, so a message reaching several locally-registered agents would fire listeners N times — the fan-in dedupes across sources by event identity within a bounded window. Per-source transport events (error, reconnecting) are never deduped.
  • The workspace stream is kept only as a pre-registration fallback and detached as soon as the first agent source connects, so it no longer sits in a doomed reconnect loop.
  • Failures are loud now: listener events.connect() errors route through the onError hooks instead of an empty catch, and a listener still waiting with no registered agent reports an actionable error after a 10s grace period.

Also updates the listeners-test event bus fake to mirror the real client's emit contract (events fan out to their type key and 'any'), and adds a Fixed entry to [Unreleased] in CHANGELOG.md.

Test Plan

  • Tests added/updated — 11 new unit tests in packages/sdk/src/__tests__/event-fanin.test.ts covering fan-in forwarding, late-source connect, cross-source dedupe, transport-event passthrough, fallback attach/detach, subscription replay, the no-source warning, and AgentRelay integration in both orders. Full SDK suite green: 121/121.
  • Manual testing completed — reproduced the bug end-to-end against a self-hosted engine 5.0.11 (README quickstart pattern received nothing; engine DB showed the listener's delivery stuck queued with its direct node offline). After the fix, the same script receives the #general message in both register-then-listen and listen-then-register orders, and the engine DB shows deliveries acked.

Screenshots

N/A

🤖 Generated with Claude Code

https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe


Generated by Claude Code

Review in cubic

…ients

relay.addListener(...) on a workspace-key AgentRelay silently received
nothing against relaycast v5. The listener hub connected the workspace
client's event stream, which opens the legacy /v1/ws workspace stream —
but v5 rejects workspace keys there (observer token required), so the
socket 401'd into an endless reconnect loop, no error surfaced, and the
engine's queued deliveries for the listening agent were never drained
(its implicit direct node never connected).

Route the workspace-level listener hub through a new events fan-in
instead: every agent client created by workspace.register()/reconnect()
is added as a source and connected over the v5 node transport once a
listener exists. Events that reach multiple locally-registered agents
(one deliver frame per recipient) are deduplicated across sources within
a bounded window; per-source transport events pass through. The
workspace stream is kept only as a pre-registration fallback and is
detached as soon as the first agent source connects.

Failures are no longer silent: listener connect errors route through
the onError hooks instead of an empty catch, and a listener still
waiting with no registered agent reports after a grace period.

Verified end-to-end against a self-hosted engine (5.0.11): the README
quickstart pattern now receives #general messages in both orders
(register-then-listen and listen-then-register), with deliveries acked
server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
@willwashburn
willwashburn requested a review from khaliqgant as a code owner July 2, 2026 03:10
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds observer-mode workspace streaming, a shared listener fan-in, broker self-muting for offline self identities, and matching spec, changelog, and test coverage.

Changes

Observer Plane Streaming and Listener Fan-In

Layer / File(s) Summary
Observer plane contract
specs/observer-plane.md
Defines the observer-plane log, backfill endpoint, live stream behavior, cursor protocol, rollout order, and open questions.
Observer event source
packages/sdk/src/messaging/observer-source.ts, packages/sdk/src/messaging/index.ts, packages/sdk/src/messaging/normalize.ts, CHANGELOG.md
Defines the observer live-stream and options types, builds the REST-backed event source, re-exports the observer messaging API, maps raw reaction frames into normalized reaction events, and records the observer-mode release notes.
Shared listener fan-in
packages/sdk/src/messaging/event-fanin.ts, packages/sdk/src/listeners.ts, packages/sdk/src/__tests__/listeners.test.ts, packages/sdk/src/__tests__/event-fanin.test.ts
Implements event fan-in with cross-source deduplication, fallback handling, warning timers, and listener error reporting, then verifies listener fan-out and fan-in behavior.
AgentRelay observer wiring
packages/sdk/src/agent-relay.ts, packages/sdk/src/__tests__/observer-source.test.ts
Routes AgentRelay listener traffic through the fan-in, adds observer-mode read-only workspace behavior, and validates observer-mode listener and workspace behavior in tests.
Broker self-mute
crates/broker/src/relaycast/ws.rs, crates/broker/src/runtime/api.rs, crates/broker/src/runtime/tests.rs
Mutates ensured default and extra channels to mute the broker-self agent after channel joins and updates default observer scopes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: khaliqgant

Poem

I twitched my nose through streams so bright,
With cursors snug and seqs in sight.
I nibbled warnings, mmm, just right,
And bounced deduped events through the night.
🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: relaying addListener events through registered agent clients.
Description check ✅ Passed The description follows the required template with Summary, Test Plan, and Screenshots sections and includes concrete testing details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/agent-relay-sdk-review-78901d

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.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces an event fan-in mechanism (createEventFanIn) to stream events through registered agent clients over the v5 node transport, fixing relay.addListener(...) for workspace-key clients. The feedback focuses on enhancing the robustness of the fan-in implementation by tracking and cleaning up event subscriptions on disconnect to prevent memory leaks, defensively checking for optional methods on sources to avoid runtime errors, and refactoring the changelog entry to be more concise and impact-first in accordance with the repository style guide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +272 to +277
await Promise.allSettled(sources.map((source) => Promise.resolve().then(() => source.disconnect())));
},

subscribe: (channels) => {
for (const channel of channels) desiredChannels.add(channel);
for (const source of sources) {

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.

high

During disconnect(), unsubscribe from all active sources to prevent memory leaks and stop forwarding events. Additionally, defensively check if source.disconnect is a function before calling it, as partial fakes or custom event surfaces might not implement it.

Suggested change
await Promise.allSettled(sources.map((source) => Promise.resolve().then(() => source.disconnect())));
},
subscribe: (channels) => {
for (const channel of channels) desiredChannels.add(channel);
for (const source of sources) {
disconnect: async () => {
connectRequested = false;
clearNoSourceTimer();
detachFallback();
for (const unsub of unsubscribes.values()) {
unsub();
}
unsubscribes.clear();
await Promise.allSettled(
sources.map((source) =>
Promise.resolve().then(() => {
if (typeof source.disconnect === 'function') {
return source.disconnect();
}
})
)
);
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62. disconnect() now detaches all source forwarding via a tracked sourceForwarding map (so a source reconnected later by another owner can't feed explicitly-disconnected listeners), guards source.disconnect with a typeof check, and connect() re-attaches forwarding for sources dropped by a prior disconnect. Covered by the new "disconnect() stops forwarding; connect() resumes it" test.


Generated by Claude Code

Comment thread CHANGELOG.md Outdated
### Fixed

- `HarnessDriverClient.spawn()` now polls the broker's startup handshake for the full `startupTimeoutMs` budget (default 45s) instead of a fixed ~10s, so a slow-but-healthy Relaycast handshake that keeps answering `503` while warming up is no longer misreported as a spawn failure.
- `@agent-relay/sdk` `relay.addListener(...)` on a workspace-key client now receives channel messages, DMs, and thread replies. It previously listened on the legacy `/v1/ws` workspace stream, which relaycast v5 rejects for workspace keys (observer token required), so the socket 401'd into a silent reconnect loop and listeners got nothing while deliveries queued unread. The workspace-level listener hub now streams through every registered agent client (`workspace.register`/`reconnect`) over the v5 node transport, deduplicating events that reach multiple locally-registered agents; the workspace stream remains only as a pre-registration fallback. Listener `events.connect()` failures are reported through `onError` instead of being swallowed, and a listener left waiting with no registered agent warns after 10s.

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.

medium

The changelog entry is too long and contains detailed implementation backstory (e.g., legacy workspace stream details, 401 errors, and reconnect loops). According to the repository style guide, changelog entries should be concise and impact-first, dropping implementation backstory unless necessary to explain the shipped impact.

Suggested change
- `@agent-relay/sdk` `relay.addListener(...)` on a workspace-key client now receives channel messages, DMs, and thread replies. It previously listened on the legacy `/v1/ws` workspace stream, which relaycast v5 rejects for workspace keys (observer token required), so the socket 401'd into a silent reconnect loop and listeners got nothing while deliveries queued unread. The workspace-level listener hub now streams through every registered agent client (`workspace.register`/`reconnect`) over the v5 node transport, deduplicating events that reach multiple locally-registered agents; the workspace stream remains only as a pre-registration fallback. Listener `events.connect()` failures are reported through `onError` instead of being swallowed, and a listener left waiting with no registered agent warns after 10s.
- @agent-relay/sdk: Fix `relay.addListener(...)` on workspace-key clients to correctly receive channel messages, DMs, and thread replies by streaming through registered agent clients over the v5 node transport. Listener connection failures are now reported via `onError` instead of being swallowed, and a warning is issued if no registered agent is connected after 10 seconds.
References
  1. Changelog entries should be concise and impact-first. Prefer one short bullet per user-visible change: name the command, API, schema, or package touched and the practical effect. Drop issue/PR links, internal review notes, implementation backstory, release-only entries, and 'foundation for...' phrasing unless that text clearly explains the shipped impact. (link)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62 — trimmed to a single impact-first bullet (package, API, practical effect), dropping the 401/reconnect-loop backstory.


Generated by Claude Code

Comment on lines +125 to +126
if (options.onError) {
try {

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.

medium

To prevent memory leaks and avoid forwarding events after disconnect(), we should track the unsubscribe functions returned by source.on('any', forward) and invoke them during disconnection. Let's declare a map to store these unsubscribe functions.

Suggested change
if (options.onError) {
try {
let fallbackConnected = false;
let noSourceTimer: ReturnType<typeof setTimeout> | undefined;
const unsubscribes = new Map<RelayMessagingEventsSurface, () => void>();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62 — forwarding unsubscribes are tracked in a sourceForwarding map (keyed by surface) and detached on disconnect().


Generated by Claude Code

Comment on lines +243 to +258
if (seenSources.has(source)) return;
seenSources.add(source);
sources.push(source);
source.on('any', forward);
if (connectRequested) {
connectSource(source);
clearNoSourceTimer();
// The agent transport is the real event stream; stop the workspace
// fallback so it does not sit in a doomed reconnect loop.
detachFallback();
}
},

hasAgentSources: () => sources.length > 0,

connect: () => {

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.

medium

Store the unsubscribe function returned by source.on('any', forward) in the unsubscribes map so we can clean it up later.

Suggested change
if (seenSources.has(source)) return;
seenSources.add(source);
sources.push(source);
source.on('any', forward);
if (connectRequested) {
connectSource(source);
clearNoSourceTimer();
// The agent transport is the real event stream; stop the workspace
// fallback so it does not sit in a doomed reconnect loop.
detachFallback();
}
},
hasAgentSources: () => sources.length > 0,
connect: () => {
addSource: (source) => {
// A source without a subscribable stream (partial fakes injected via
// `createAgentMessaging`) contributes nothing; skip it entirely.
if (!source || typeof source.on !== 'function') return;
if (seenSources.has(source)) return;
seenSources.add(source);
sources.push(source);
const unsub = source.on('any', forward);
unsubscribes.set(source, unsub);
if (connectRequested) {
connectSource(source);
clearNoSourceTimer();
// The agent transport is the real event stream; stop the workspace
// fallback so it does not sit in a doomed reconnect loop.
detachFallback();
}
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62addSource wires forwarding through attachSourceForwarding, which stores the unsubscribe in the sourceForwarding map for later detach.


Generated by Claude Code

Comment on lines +262 to +270
return;
}
attachFallback();
scheduleNoSourceWarning();
},

disconnect: async () => {
connectRequested = false;
clearNoSourceTimer();

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.

medium

When reconnecting, ensure we re-subscribe to any sources that were unsubscribed during disconnect().

    connect: () => {
      connectRequested = true;
      if (sources.length > 0) {
        for (const source of sources) {
          if (!unsubscribes.has(source)) {
            const unsub = source.on('any', forward);
            unsubscribes.set(source, unsub);
          }
          connectSource(source);
        }
        return;
      }
      attachFallback();
      scheduleNoSourceWarning();
    },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62connect() calls attachSourceForwarding for every source (idempotent via the map), so forwarding dropped by a prior disconnect() is re-attached. Covered by the new disconnect/reconnect test.


Generated by Claude Code

Comment on lines +228 to +240
if (fallbackConnected) {
fallbackConnected = false;
try {
void fallback?.disconnect().catch(() => {});
} catch {
// Fallback surfaces without a disconnect are simply left as-is.
}
}
};

return {
addSource: (source) => {
// A source without a subscribable stream (partial fakes injected via

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.

medium

If fallback is a partial fake or custom object that does not implement disconnect, or if disconnect returns a non-Promise value, calling fallback?.disconnect().catch(...) can throw a TypeError (e.g., if disconnect is undefined or returns void). Let's defensively check if disconnect is a function and wrap it safely.

  const detachFallback = (): void => {
    if (!fallbackForwarding) return;
    fallbackForwarding();
    fallbackForwarding = undefined;
    if (fallbackConnected) {
      fallbackConnected = false;
      try {
        if (typeof fallback?.disconnect === 'function') {
          void fallback.disconnect().catch(() => {});
        }
      } catch {
        // Fallback surfaces without a disconnect are simply left as-is.
      }
    }
  };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62detachFallback now checks typeof fallback?.disconnect === 'function' before calling, with the try/catch retained for a disconnect that returns a non-promise.


Generated by Claude Code

Comment on lines +279 to +299
source.subscribe(channels);
} catch (error) {
report(error);
}
}
},

unsubscribe: (channels) => {
for (const channel of channels) desiredChannels.delete(channel);
for (const source of sources) {
try {
source.unsubscribe(channels);
} catch (error) {
report(error);
}
}
},

on: <K extends keyof RelayMessagingEventMap>(
event: K,
handler: (...args: RelayMessagingEventMap[K]) => void | Promise<void>

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.

medium

In subscribe and unsubscribe, we should defensively check if source.subscribe or source.unsubscribe are functions before calling them. Otherwise, calling them on partial fakes or custom event surfaces will throw a TypeError and trigger the report error handler, causing noisy warnings or error hook invocations.

    subscribe: (channels) => {
      for (const channel of channels) desiredChannels.add(channel);
      for (const source of sources) {
        if (typeof source.subscribe === 'function') {
          try {
            source.subscribe(channels);
          } catch (error) {
            report(error);
          }
        }
      }
    },

    unsubscribe: (channels) => {
      for (const channel of channels) desiredChannels.delete(channel);
      for (const source of sources) {
        if (typeof source.unsubscribe === 'function') {
          try {
            source.unsubscribe(channels);
          } catch (error) {
            report(error);
          }
        }
      }
    },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in d71fc62subscribe/unsubscribe now skip sources whose surface lacks the method (typeof guard) instead of routing a TypeError through the error hooks.


Generated by Claude Code

@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: 543d5ddf8a

ℹ️ 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".

Comment on lines +202 to +204
if (desiredChannels.size > 0 && typeof source.subscribe === 'function') {
try {
source.subscribe([...desiredChannels]);

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 Subscribe agent streams before relying on channel listeners

In the documented workspace.register(...); channels.join(...); relay.addListener('message.created', ...) flow, desiredChannels is empty because addListener never records the joined channel, so this code only opens the agent socket and never calls source.subscribe(...). The underlying relaycast agent client sends channel subscription frames only for channels passed to subscribe, so channel message.created/thread.reply events still won't arrive unless callers make an extra undocumented relay.events.subscribe(['channel']) call.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This doesn't match how v5 delivers channel messages, so no change here. On the node transport, message.created/thread.reply are not gated on WS subscribe frames: the engine creates durable delivery rows from server-side channel membership (buildChannelDeliveryWrite selects from channel_members) and pushes them as deliver frames; agents are auto-joined to #general at registration, and channels.join(...) is the membership operation. The WS-level subscribe op is a legacy /v1/ws concern — the engine's node-control handler doesn't even have a subscribe message type.

Verified E2E against a self-hosted engine 5.0.11: the exact register → addListener('message.created') flow with no events.subscribe call receives #general messages (and the delivery rows show acked). desiredChannels replay in connectSource exists only to preserve the explicit events.subscribe API's behavior across late-connecting sources, not as a delivery prerequisite.


Generated by Claude Code

Comment on lines +153 to +157
const key = dedupeKey(event);
if (key) {
const at = now();
const prev = seenEvents.get(key);
if (prev !== undefined && at - prev < dedupeWindowMs) return;

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 Deduplicate only cross-source duplicates

This drops any repeated keyed event within the 30s window, even when it comes from the same source. For example, two message.updated events for the same message (or a remove/re-add reaction cycle by the same agent) within 30 seconds will make the second real event return here and never reach relay.addListener, because the dedupe state is global rather than scoped to cross-source duplicates.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in d71fc62. Dedupe is now source-aware: each key tracks the set of sources that delivered the current occurrence. A copy from a source already in the set is a genuine repeat (message edit, re-reaction) and starts a new occurrence, so same-source repeats always pass through; only copies from other sources within the window collapse. Covered by the new "passes genuine repeats from the same source while collapsing cross-source copies" test using two message.updated events for the same message.


Generated by Claude Code

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
CHANGELOG.md (1)

29-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim the changelog entry for conciseness.

This bullet spans several sentences covering internal mechanics (/v1/ws, 401 handling, node transport, dedup logic, onError, 10s timer) rather than a short, impact-first summary. As per coding guidelines, "Changelog entries should be concise and impact-first, with one short bullet per user-visible change. Omit issue/PR links, internal notes, and implementation details."

📝 Suggested tighter entry
-- `@agent-relay/sdk` `relay.addListener(...)` on a workspace-key client now receives channel messages, DMs, and thread replies. It previously listened on the legacy `/v1/ws` workspace stream, which relaycast v5 rejects for workspace keys (observer token required), so the socket 401'd into a silent reconnect loop and listeners got nothing while deliveries queued unread. The workspace-level listener hub now streams through every registered agent client (`workspace.register`/`reconnect`) over the v5 node transport, deduplicating events that reach multiple locally-registered agents; the workspace stream remains only as a pre-registration fallback. Listener `events.connect()` failures are reported through `onError` instead of being swallowed, and a listener left waiting with no registered agent warns after 10s.
+- `@agent-relay/sdk` `relay.addListener(...)` on a workspace-key client now correctly receives channel messages, DMs, and thread replies instead of silently getting nothing; connect failures now surface via `onError`, and an unregistered listener warns after 10s.
🤖 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 `@CHANGELOG.md` at line 29, The changelog bullet in the main entry is too long
and includes internal implementation details. Shorten the entry in CHANGELOG.md
to an impact-first, user-visible summary for `@agent-relay/sdk` relay.addListener
behavior, and remove references to /v1/ws, 401 reconnect loops, node transport,
deduplication, onError handling, and the 10s warning.

Source: Path instructions

🤖 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/src/messaging/event-fanin.ts`:
- Around line 71-85: The dedupe logic in eventFanin/getEventKey is using coarse
entity-based keys for repeatable state events, which can incorrectly collapse
legitimate transitions for the same agent or channel. Update the key generation
in event-fanin.ts so only events with stable unique IDs are deduped, or derive a
canonical payload identity for cases like agentOnline, channelUpdated, and
memberJoined instead of using just event.type plus agent/channel fields. Keep
the fix centered around getEventKey and the seenEvents dedupe path so repeat
events from the same source are not dropped.
- Around line 207-221: The fallback stream in event-fanin is not receiving
channel subscriptions that were registered before sources existed. Update the
subscription flow in the subscribe/unsubscribe logic and attachFallback() so the
active fallback mirrors desiredChannels whenever fallbackForwarding is active,
including replaying existing channels when the fallback attaches and keeping
them in sync on later subscribe/unsubscribe calls. Use the event-fanin helpers
around attachFallback, subscribe, and unsubscribe to ensure the fallback
observes the same channel set as sources.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Line 29: The changelog bullet in the main entry is too long and includes
internal implementation details. Shorten the entry in CHANGELOG.md to an
impact-first, user-visible summary for `@agent-relay/sdk` relay.addListener
behavior, and remove references to /v1/ws, 401 reconnect loops, node transport,
deduplication, onError handling, and the 10s warning.
🪄 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: 55380139-c650-4503-a1e8-b13fc676211c

📥 Commits

Reviewing files that changed from the base of the PR and between 7a98c6e and 21281eb.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • packages/sdk/src/__tests__/event-fanin.test.ts
  • packages/sdk/src/__tests__/listeners.test.ts
  • packages/sdk/src/agent-relay.ts
  • packages/sdk/src/listeners.ts
  • packages/sdk/src/messaging/event-fanin.ts
  • packages/sdk/src/messaging/index.ts

Comment thread packages/sdk/src/messaging/event-fanin.ts
Comment thread packages/sdk/src/messaging/event-fanin.ts

@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 and verified against the latest diff

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/src/messaging/event-fanin.ts">

<violation number="1" location="packages/sdk/src/messaging/event-fanin.ts:219">
P2: `attachFallback()` connects the workspace stream but never replays `desiredChannels` onto it, and `subscribe()`/`unsubscribe()` only iterate `sources` (which excludes the fallback). If channel-scoped subscriptions are added while the fallback is the only active stream (before any agent registers), those channels won't be subscribed on the fallback transport, so channel-scoped events may not arrive during the pre-registration window.

Consider replaying `desiredChannels` in `attachFallback()` after `connect()`, and mirroring `subscribe`/`unsubscribe` calls to the fallback while `fallbackForwarding` is active.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk/src/messaging/event-fanin.ts Outdated
Comment thread packages/sdk/src/messaging/event-fanin.ts Outdated
if (typeof fallback.connect === 'function') {
try {
fallback.connect();
fallbackConnected = true;

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: attachFallback() connects the workspace stream but never replays desiredChannels onto it, and subscribe()/unsubscribe() only iterate sources (which excludes the fallback). If channel-scoped subscriptions are added while the fallback is the only active stream (before any agent registers), those channels won't be subscribed on the fallback transport, so channel-scoped events may not arrive during the pre-registration window.

Consider replaying desiredChannels in attachFallback() after connect(), and mirroring subscribe/unsubscribe calls to the fallback while fallbackForwarding is active.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk/src/messaging/event-fanin.ts, line 219:

<comment>`attachFallback()` connects the workspace stream but never replays `desiredChannels` onto it, and `subscribe()`/`unsubscribe()` only iterate `sources` (which excludes the fallback). If channel-scoped subscriptions are added while the fallback is the only active stream (before any agent registers), those channels won't be subscribed on the fallback transport, so channel-scoped events may not arrive during the pre-registration window.

Consider replaying `desiredChannels` in `attachFallback()` after `connect()`, and mirroring `subscribe`/`unsubscribe` calls to the fallback while `fallbackForwarding` is active.</comment>

<file context>
@@ -0,0 +1,313 @@
+    if (typeof fallback.connect === 'function') {
+      try {
+        fallback.connect();
+        fallbackConnected = true;
+      } catch (error) {
+        // A workspace-key client may have no stream at all; agent sources can
</file context>

Comment thread packages/sdk/src/listeners.ts
claude added 5 commits July 2, 2026 03:23
…anup

Review follow-ups on the events fan-in:

- Dedupe is now source-aware: only cross-source copies of one occurrence
  collapse. A repeat from a source that already delivered the previous
  occurrence (message edit, re-reaction within the window, presence flap)
  starts a new occurrence and always passes through — the previous global
  key window could drop legitimate same-source repeats.
- disconnect() detaches all source forwarding (and connect() re-attaches
  it), so a source reconnected later by another owner cannot feed
  listeners that were explicitly disconnected.
- Defensive typeof guards on source/fallback disconnect, subscribe, and
  unsubscribe so partial surfaces injected via createAgentMessaging don't
  produce noisy TypeErrors through the error hooks.
- Trimmed the changelog entry to the impact-first house style.

Test fakes' emit now mirrors the real client contract without
double-firing when emitting the literal 'any' key. E2E re-verified
against a self-hosted engine (both listener orders receive #general).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
… stream

new AgentRelay({ observerToken }) streams relay.addListener(...) from the
workspace observer plane instead of the registered-agent fan-in. A new
observer event source implements the cursor protocol: connect the live
/v1/ws observer stream and buffer frames, REST-backfill
GET /v1/workspace/events from the in-memory cursor (sinceSeq, default 0)
paginating until latest_seq, then emit backfilled events followed by the
buffered/live ones deduped and ordered by seq. Frames without a seq
(server-side log append failed) pass through as live-only. A 404 backfill
(older engines) degrades gracefully to live-only; the cursor still advances
from live seq. Persisting the cursor stays with the caller via
sinceSeq/onCursor.

Both legs flow through normalizeMessagingEvent, so listeners receive the
same public event shapes as every other source. In observer mode the
source is the fan-in's sole source (no workspace-stream fallback, no
no-agent-source warning), and workspace.register()/reconnect() throw a
clear read-only error at the facade boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
ensure_default_channels/ensure_extra_channels join the broker-self agent
to channels so the broker can post to them, but that membership also made
the engine's channel fan-out write a delivery row per message to
broker-self's permanently-offline implicit direct node — rows that queued
forever and churned through TTL expiry. The engine skips muted members in
channel delivery fan-out, so mute each channel for the self agent right
after ensure_joined_channel succeeds (POST /v1/channels/:name/mute via the
relaycast crate's AgentClient::mute_channel). Best-effort: failures log a
warning and never fail startup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
…roker self-mute

specs/observer-plane.md documents the two-plane architecture: the bug
class motivating it (lossy workspace stream on reconnect, Pear polling
reconciliation, broker relay_inbound blind to remote recipients,
dashboard-identity deliveries queuing forever), the unchanged v5
participant plane, the observer-plane contract (event log table, seq
semantics, GET /v1/workspace/events, client cursor protocol),
per-component changes, and the migration order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe
Comment thread packages/sdk/src/messaging/observer-source.ts Fixed

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 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/src/messaging/observer-source.ts`:
- Around line 207-209: The backfill fetch in observer-source’s request flow can
hang indefinitely, leaving backfillDone false and causing pending live frames to
buffer forever. Update the fetchImpl call in the backfill logic to use an
abortable timeout so stalled requests fail promptly, and make sure the existing
catch path in observer-source degrades to live-only delivery when the timeout
aborts.
- Around line 248-256: The live stream setup in observer-source’s connect flow
leaves a partially initialized `live` instance assigned when
`createLiveStream()`, `live.on.any(handleLiveFrame)`, or `live.connect()`
throws, which blocks later retries. Update the `connect()` path to clear or
reset `live` (and any related listener state like `offLive`) inside the catch
block before returning, so subsequent calls can create a fresh stream and retry
initialization.
- Around line 195-200: The buffered frame ordering in observer-source is not
total, so seq-less frames can compare equal to both seq frames and be delivered
out of order before the cursor advances. Update the sorting logic in the
buffered delivery path to order all pending frames deterministically in the
frame-processing flow around readSeq and deliver, ensuring seq-bearing frames
are ordered consistently ahead of advancing the cursor and seq-less frames do
not break ordering. After the sort, verify the cursor only advances after the
correct lowest-seq frames have been delivered.
🪄 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: f310e628-56d4-4199-8923-0242a022e307

📥 Commits

Reviewing files that changed from the base of the PR and between f1a5c92 and b0959ea.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/broker/src/relaycast/ws.rs
  • packages/sdk/src/__tests__/observer-source.test.ts
  • packages/sdk/src/agent-relay.ts
  • packages/sdk/src/messaging/index.ts
  • packages/sdk/src/messaging/observer-source.ts
  • specs/observer-plane.md
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/sdk/src/messaging/index.ts

Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts

@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.

All reported issues were addressed across 7 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts
Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
…tion

Cross-repo E2E against the new durable event log surfaced three seams in
the observer plane:

- The observer source's live leg used the RelayCast client, whose schema
  parsing strips the top-level `seq` from frames — the cursor never
  advanced from live events, so resume re-delivered everything. The
  default live stream is now a raw WebSocket to /v1/ws (capped-backoff
  reconnect, re-backfill from the cursor on every reopen) so frames
  arrive with `seq` intact.
- Raw engine frames carry reactions as one `message.reacted` type with an
  `action` field (higher-level clients split it before normalize sees
  it); normalizeMessagingEvent now maps that shape to
  reactionAdded/reactionRemoved so observer listeners receive reactions.
- The broker's default observer-token scopes gain `reactions:read` —
  the live stream filters message.reacted for tokens without it, so
  UI tokens minted via POST /api/observer-token silently lost reactions.

Verified end-to-end against a self-hosted engine: live observation with
cursor advance (messages and reactions), read-only enforcement, and an
offline gap recovered exactly via cursor backfill with no duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 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/src/messaging/observer-source.ts`:
- Around line 84-90: The WebSocket handlers in observer-source.ts are swallowing
abnormal failures and immediately reconnecting, which can hide
auth/permission/server rejection issues. Update the ws.onclose and ws.onerror
logic in the observer source connection flow so that non-clean close/error
events are forwarded through onError before any reconnect is scheduled, while
preserving the existing reconnect behavior for normal disconnects. Use the
existing socket, scheduleReconnect, and onError handling paths to distinguish
abnormal failures from routine closes.
- Around line 22-24: The default observer source in observer-source.ts assumes a
global WebSocket, but packages/sdk still supports Node 20.9.x where that global
is unavailable. Update the observer source implementation (and its associated
WebSocket setup logic) to either use a non-global fallback path for WebSocket
creation or raise the package engine floor so the supported Node range matches
the runtime requirement. Ensure the change is localized around the
observer-source module and any WebSocket client initialization helpers it uses.
🪄 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: ec42c899-821e-40a1-b2d5-00743f3e1d9a

📥 Commits

Reviewing files that changed from the base of the PR and between b0959ea and 93aad7d.

📒 Files selected for processing (5)
  • crates/broker/src/runtime/api.rs
  • crates/broker/src/runtime/tests.rs
  • packages/sdk/src/__tests__/observer-source.test.ts
  • packages/sdk/src/messaging/normalize.ts
  • packages/sdk/src/messaging/observer-source.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/sdk/src/tests/observer-source.test.ts

Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts Outdated

@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.

3 issues found across 5 files (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="packages/sdk/src/messaging/observer-source.ts">

<violation number="1" location="packages/sdk/src/messaging/observer-source.ts:53">
P1: The default observer live stream now requires a global `WebSocket`, which is only available in Node 21+. The repo’s declared Node engine is `>=20.9.0`, so SDK consumers on Node 20 will see a live-stream error instead of receiving observer events. The previous `RelayCast` client provided its own transport and supported the existing engine range. Please either add a WebSocket polyfill (e.g., `ws`) for the default Node transport, allow callers to inject a `WebSocket` implementation, or align the SDK’s engine requirement with this new runtime dependency.</violation>

<violation number="2" location="packages/sdk/src/messaging/observer-source.ts:84">
P2: **Observer WebSocket failures are silently swallowed, undermining the louder-error goal.** The new raw WebSocket live stream only schedules a reconnect on `ws.onclose` and leaves `ws.onerror` as a no-op. That means invalid observer tokens, server-side socket drops, or other connection failures enter an endless silent reconnect loop instead of reaching the `onError` hook, even though the `ObserverEventSourceOptions.onError` docstring says it receives live-stream failures.

Consider routing abnormal WebSocket closures through `report(...)`. The `closed` flag is already set before an intentional `disconnect()`, so you can guard against false positives during teardown. For example, in `ws.onclose` report a non-clean close with the close code (and reason, if present), then let the reconnect logic continue. This keeps the auto-reconnect behavior but gives callers the actionable signal they need to debug authentication or network issues.</violation>

<violation number="3" location="packages/sdk/src/messaging/observer-source.ts:358">
P2: The `on.open` handler registered in `connect()` is never unsubscribed. You store and clean up `offLive` for `on.any`, but the `open` subscription is discarded. If a reused `ObserverLiveStream` instance is supplied (which the test fakes do), each connect/disconnect cycle adds another stale `open` handler that triggers `runBackfill()` on every reconnect, causing duplicate backfill work and potential duplicate event delivery. Capture the `open` unsubscribe and call it in `disconnect()` alongside `offLive`.</violation>
</file>

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

Re-trigger cubic

Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Hardens the observer live leg against the CodeQL findings on the raw
WebSocket connection:

- The token no longer travels in the URL: it is sent as an
  Authorization: Bearer header via Node's undici WebSocket constructor
  options (the engine's upgrade path accepts both forms). Runtimes whose
  WebSocket rejects or ignores constructor options — browsers, per the
  WHATWG signature — are detected (constructor throw, or close before
  the first open) and downgraded once to the server's ?token= query
  convention.
- The stream URL scheme is always wss:// except for loopback hosts
  (local self-hosted engines), instead of blindly mapping http -> ws.

E2E re-verified against a self-hosted engine over the header-auth path:
live observation, reactions, and cursor backfill after an offline gap
all intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe

@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.

All reported issues were addressed across 2 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/sdk/src/messaging/observer-source.ts
The engine's GET /v1/workspace/events now returns next_since — the seq
of the last row the server's scan consumed, visible or hidden — so
scoped observer tokens whose windows are fully filtered server-side
still make pagination progress. The backfill loop advances the cursor
by it (hidden events are never delivered live to that token either, so
skipping their seqs is safe) and stops on any page that makes no
progress, preserving behavior against older engines without the field.
Spec updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe

@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.

All reported issues were addressed across 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/sdk/src/messaging/observer-source.ts Outdated
Picks up the release carrying the durable workspace event log
(relaycast v5.1.0). Verified end-to-end against the PUBLISHED
@relaycast/engine 5.1.0 from npm: SDK observer mode receives live
messages, reactions, and presence transitions with seq-stamped frames,
and recovers an offline gap exactly via cursor backfill — including the
presence events that now flow through the log.

SDK suite 135/135, build and typecheck clean against 5.1.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VASvErz2MxkQMCsSzTgsbe

@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.

All reported issues were addressed across 4 files (changes from recent commits).

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

Re-trigger cubic

Comment thread CHANGELOG.md
claude added 2 commits July 15, 2026 19:39
Resolve conflicts:
- packages/sdk/package.json, packages/cli/package.json: take main's dep
  versions (@relaycast/sdk ^6.0.0; CLI internal deps 10.2.0; @relayfile/client
  ^0.10.21; CLI no longer declares a direct @relaycast/sdk dep).
- CHANGELOG.md: main released the prior [Unreleased] block as 10.2.0; re-add
  only this PR's genuinely-new entries (observer mode; addListener workspace-key
  fix; broker self-mute) to the current [Unreleased]. Drop the stale relaycast
  5.1.0 bullet (main documents the 6.0 upgrade in released sections).
- package-lock.json: regenerated; already consistent with main's v6.

Also register the new event-fanin and observer-source test files in the SDK
test script so CI runs them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qAFBAdEa5jmViDPSHqZYK
Observer source (packages/sdk/src/messaging/observer-source.ts):
- Replace the ReDoS-prone `/\/+$/` trailing-slash regexes with a linear
  `stripTrailingSlashes` walk (resolves the CodeQL polynomial-regex alert).
- Make pending-frame flush a total order: sort seq-stamped frames out-of-band
  and splice them back into the seq-less skeleton, so a seq-less frame between
  out-of-order seq frames can no longer advance the cursor past an undelivered
  lower seq.
- Bound each backfill page fetch with an AbortController timeout
  (`backfillTimeoutMs`, default 30s) so a hung endpoint degrades to live-only
  instead of buffering forever.
- Clear `live`/`offLive`/`offOpen` and tear down a partially initialized live
  stream on connect failure so later connect() calls retry instead of no-oping.
- Capture and unsubscribe the `on.open` handler on disconnect (no stale
  re-backfill handlers across reconnect cycles).
- Never downgrade to a token-in-URL on Node (header auth always works there);
  only browser-like runtimes fall back, and only after the constructor throws
  or two pre-open closes — removing the persistent security regression from a
  single transient blip. Report abnormal WebSocket closes through onError.
- Add an injectable `webSocketImpl` and an accurate missing-WebSocket error
  (Node exposes global WebSocket only from v22).
- Warn instead of silently stopping when a scoped backfill makes no progress.

Listeners (packages/sdk/src/listeners.ts):
- Tag the addListener connect-failure path with operation: 'connect' and word
  its log distinctly so a transport failure no longer reads as a user handler
  throwing.

Tests: register the new files in the SDK test script (prior commit) and add
coverage for total-order flush, the Node no-URL-token/abnormal-close guard,
backfill-timeout degradation, connect-failure retry, and the no-progress warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qAFBAdEa5jmViDPSHqZYK

Copy link
Copy Markdown
Member Author

Rebased onto main (relaycast v5 → v6) + review findings addressed

This PR was ~2 weeks / 170 commits behind main, which has since moved @relaycast/sdk from v5 → v6. Merged main in and reconciled everything against v6; the observer plane and listener fan-in typecheck and pass their tests unchanged against v6 (the SDK's public type surface was stable across the major bump).

Merge conflict resolution

  • packages/sdk/package.json, packages/cli/package.json → main's dep set (@relaycast/sdk ^6.0.0; CLI internal deps 10.2.0; CLI no longer declares a direct @relaycast/sdk — resolved via workspace hoisting, matching main).
  • CHANGELOG.md → main released the prior [Unreleased] block as 10.2.0; re-added only this PR's genuinely-new entries (observer mode; addListener workspace-key fix; broker self-mute). Dropped the stale @relaycast/sdk 5.1.0 bullet since main documents the 6.0 upgrade in released sections — this also resolves the CHANGELOG version-conflict finding.
  • package-lock.json regenerated (already consistent with v6).
  • Registered the new event-fanin / observer-source test files in the SDK test script — they existed but were never in the CI npm test list, so CI wasn't running them.

Review findings fixed (observer-source.ts unless noted)

  • CodeQL high-severity ReDoS — replaced the /\/+$/ trailing-slash regexes with a linear stripTrailingSlashes walk.
  • Token-in-URL security regression — Node never downgrades to a ?token= URL (header auth always works there); only browser-like runtimes fall back, and only after the constructor throws or two pre-open closes, so a single transient blip can't cause a persistent downgrade.
  • Abnormal WebSocket closes now surface through onError instead of a silent reconnect loop.
  • Seq-less frame orderingflushPending now sorts sequenced frames out-of-band and splices them back into the seq-less skeleton, so a seq-less frame between out-of-order seq frames can't advance the cursor past an undelivered lower seq.
  • Backfill fetch now has an AbortController timeout (backfillTimeoutMs, default 30s) → degrades to live-only instead of buffering forever.
  • Partially-initialized live stream cleared on connect failure so later connect() retries; the on.open handler is captured and unsubscribed on disconnect().
  • Node WebSocket floor — added an injectable webSocketImpl and an accurate missing-WebSocket error (Node exposes a global WebSocket only from v22; earlier supported runtimes should polyfill or inject).
  • Silent scoped-backfill skip now warns via onError.
  • listeners.ts — the addListener connect-failure path is tagged operation: 'connect' and worded distinctly so a transport failure no longer reads as a user handler throwing.

Added unit tests for total-order flush, the Node no-URL-token / abnormal-close guard, backfill-timeout degradation, connect-failure retry, and the no-progress warning.

Validation (local, against relaycast v6)

  • SDK: typecheck clean, 150/150 tests (incl. the newly-registered files), Prettier clean.
  • Broker (Rust): compiles against relaycast crate 6.0, clippy + fmt clean, 221 runtime tests pass.
  • CLI: typechecks after workspace build; repo lint 0 errors.

The desiredChannels-on-fallback suggestion is intentionally not applied — on v6 the workspace/observer fallback is an unscoped firehose and events.subscribe throws on a workspace-key client, so mirroring subscriptions there would only route spurious errors through onError (see the earlier thread for the full rationale).

Generated by Claude Code


Generated by Claude Code

Main advanced (v10.3.0, v10.4.0 cut; SDK types refactored to derive from
@relaycast/types). Resolve:
- CHANGELOG.md: releasing v10.3.0 had folded this PR's still-unreleased entries
  (observer mode, addListener workspace-key fix, broker self-mute) into the
  published [10.3.0] section. Move them back under [Unreleased], bumped to Minor
  (observer mode is a new backward-compatible API). Keep main's new Unreleased
  entries (types refactor, canonical `message.reacted` mapping).
- normalize.ts: the auto-merge left two `case 'message.reacted'` labels — main's
  canonical handler plus this PR's observer-frame handler referencing a now-gone
  `readString` helper (a compile error). Drop the duplicate; main's grouped
  message.reacted/reaction.added/reaction.removed case (snake_case wire fields)
  already covers raw observer frames.
- package-lock.json regenerated against v10.4.0 deps (zod ^4, @relaycast/types ^6).

SDK typechecks and 151 tests pass; broker compiles against relaycast crate 6.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qAFBAdEa5jmViDPSHqZYK
@willwashburn
willwashburn merged commit 8cce018 into main Jul 15, 2026
43 checks passed
@willwashburn
willwashburn deleted the claude/agent-relay-sdk-review-78901d branch July 15, 2026 23:47
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.

3 participants