Skip to content

Have Pear subscribe directly to Relaycast's observer stream for full chat coverage#392

Open
willwashburn wants to merge 2 commits into
mainfrom
feature/pear-observer-stream
Open

Have Pear subscribe directly to Relaycast's observer stream for full chat coverage#392
willwashburn wants to merge 2 commits into
mainfrom
feature/pear-observer-stream

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Pear's dashboard has been missing chat messages that never touch the local broker's own dashboard WS — specifically:

  • Any message an agent's own MCP server posts directly to Relaycast, bypassing the broker's send path entirely.
  • Any message in a channel with no locally-attached worker (so node-controlled delivery to this broker never fires).

This PR closes that gap the way the hosted observer dashboard already does: Pear's main process opens a second, direct-to-Relaycast /v1/ws subscription per open project, scoped to an observer token (ot_live_..., stream:read), and feeds matching chat-message frames into the same renderer ingestion path that already handles relay_inbound events from the local broker WS.

This is purely additive — the existing local broker WS keeps doing everything else it's uniquely responsible for (agent spawn/exit, terminal state, delivery acks), and the existing reconciliation/active-room-polling fallback is unchanged and still serves as the catch-up safety net for any gap.

What changed (src/main/broker.ts)

  • ensureObserverStream(projectId) — mints an observer token via the existing mintObserverToken() path and opens a @relaycast/sdk WsClient connection to /v1/ws. Called from every session-start path (local spawn, attach-to-existing-broker, cloud sandbox attach, and the "existing session" re-start branch), idempotently.
  • handleObserverStreamEvent / observerStreamRelayInboundPayload — classifies message-class wire frames (message.created, thread.reply, dm.received, group_dm.received, plus the broader forward-compat alias set relay's classify_fleet_delivery uses) and synthesizes the same relay_inbound-shaped payload the local broker's own events use, using the Relaycast message's own canonical id as event_id. Routes through the same validateIngressBrokerEvent + publishBrokerEvent path ordinary broker events use — no parallel dispatch path, no renderer changes.
  • stopObserverStream — torn down in dropSession(), but only once no session (local or cloud) remains for a project, since the stream is shared across a project's sibling sessions.

Why this transport/mechanism

Checked @relaycast/sdk's exports before hand-rolling anything against raw ws: it already exports a generic WsClient (token as string-or-function, built-in exponential-backoff reconnect, resync/replay-gap handling, ping) that's exactly what the hosted observer dashboard itself uses under @relaycast/react's RelayProvider (confirmed in the relaycast repo's packages/react/src/provider.tsx and packages/observer-dashboard). AgentClient (the higher-level SDK class) is agent-identity-shaped (requires an API key, does presence/heartbeat) and doesn't fit an observer token, so this uses WsClient directly — same as the dashboard's own wsToken usage. Confirmed observer tokens are workspace-scoped server-side (wsAuth.ts's scope: 'workspace'), so no per-channel .subscribe() call is needed (the dashboard doesn't call it either when it wants "everything").

Reconnect/backoff comes for free from WsClient itself — no bespoke replay-gap machinery was added, per the plan; the existing reconciliation/active-room-polling fallback remains the safety net for any gap.

Dashboard-own-send dedup

Verified the existing isCanonicalEchoOfLocalHuman content-based dedup (matches on local: true optimistic records, regardless of which stream produced the echo) already covers a human's own send arriving twice — once via the local broker's own echo, once via this new observer stream, each under a different event_id. Added a renderer test (agent-store.stress.test.ts) proving this explicitly rather than assuming it. No additional self-skip logic was needed.

One known scope limitation: live dm.received/group_dm.received frames only carry a conversationId, not participant names (unlike the REST-based reconcileMessages history fetch, which resolves "alice, bob"-style targets). This PR falls back to the raw conversation id as the DM target so the message still surfaces rather than being dropped — a follow-up could resolve participant names via an extra lookup if that DM room-title UX matters in practice. Channel messages (the primary repro case in the plan) are fully resolved to #channel targets.

Test plan

  • npm run typecheck — clean
  • npm test — 123/123 node tests pass (baseline unchanged)
  • npx vitest run — 505/505 tests pass (498 baseline + 7 new)
  • New broker.test.ts coverage (BrokerManager observer stream): message-class frame → single relay_inbound dispatch with canonical id; thread.reply carries thread_id; non-message-class frames are not forwarded; stream disconnects on session drop and a fresh one opens on restart without leaking the old connection's events; sibling cloud-session drop does NOT tear down a still-live local session's stream; token function reuses the cached mintObserverToken path.
  • New agent-store.stress.test.ts coverage: a human's own send does not double-render when it arrives a second time via this new stream under a different id.
  • Manual: with only one local agent running (no other local worker in a channel), have a different, unrelated agent/session post into that channel — confirm it now shows up in Pear's dashboard immediately (not yet run against a live Relaycast environment from this sandbox).

🤖 Generated with Claude Code

Review in cubic

…r full chat coverage

The local broker's own dashboard WS only surfaces chat messages via two
narrow paths (the broker's own send handler, and node-controlled PTY
delivery), so it misses anything an agent's MCP server posts directly to
Relaycast (bypassing the broker send path) and anything in a channel with no
locally-attached worker (node delivery to this broker never fires).

Pear's main process now opens a second, direct-to-Relaycast /v1/ws
subscription per open project — the same mechanism the hosted observer
dashboard already uses — scoped to an observer token minted via the existing
mintObserverToken() path. Matching message-class frames are synthesized into
the same relay_inbound shape and pushed through the existing
publishBrokerEvent ingestion point, so the renderer's dedup/store logic
handles them exactly like local-broker events, unchanged.

Lifecycle is tied to the same session bookkeeping mintObserverToken's cache
already uses (dropSession, project-scoped so a project's local+cloud
sessions share one stream, torn down only once neither remains). Reconnect/
backoff comes for free from @relaycast/sdk's WsClient, which is what the
hosted observer dashboard build on already.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a per-project Relaycast observer WebSocket subscription to BrokerManager that forwards observer chat frames (message.created, thread.reply) into the existing broker event ingestion path as relay_inbound payloads, with lifecycle wiring across session start/attach/teardown paths, plus corresponding tests.

Changes

Relaycast Observer Stream Integration

Layer / File(s) Summary
Observer frame synthesis
src/main/broker.ts
Imports WsClient, defines forwardable event types, and synthesizes relay_inbound-shaped payloads from observer frames.
BrokerManager observer stream methods
src/main/broker.ts
Adds observerStreams state and ensureObserverStream, handleObserverStreamEvent, stopObserverStream methods that mint tokens, validate, and publish events.
Session lifecycle wiring
src/main/broker.ts
Calls ensureObserverStream from start/attach paths and stops the stream in dropSession only when no sessions remain for a project.
BrokerManager observer tests
src/main/broker.test.ts
Adds FakeWsClient mock and tests for forwarding, filtering, lifecycle, sibling-session survival, and token caching.
Renderer dedup stress test
src/renderer/src/stores/agent-store.stress.test.ts
Adds a test verifying observer-stream echo doesn't duplicate an already-rendered optimistic message.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Relaycast as Relaycast WS
  participant BrokerManager
  participant Validator as validateIngressBrokerEvent
  participant Renderer

  BrokerManager->>BrokerManager: ensureObserverStream(projectId)
  BrokerManager->>Relaycast: connect(WsClient, mintObserverToken)
  Relaycast-->>BrokerManager: emit observer frame (message.created/thread.reply)
  BrokerManager->>BrokerManager: handleObserverStreamEvent(projectId, event)
  BrokerManager->>Validator: validate synthesized relay_inbound payload
  Validator-->>BrokerManager: validated payload
  BrokerManager->>Renderer: publishBrokerEvent(relay_inbound)
Loading

Possibly related PRs

  • AgentWorkforce/pear#386: Observer-stream relay_inbound events feed the same renderer reconciliation logic modified there for delivery events.
  • AgentWorkforce/pear#387: Both PRs address the canonical relay_inbound echo race preventing duplicate human messages, with related tests in agent-store.stress.test.ts.

Poem

A rabbit hears the wires hum,
Two streams of chat now sync as one,
Local and cloud, side by side,
No duplicate echoes left to hide,
Hop hop — the broker forwards true! 🐰📡

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Pear now subscribes directly to Relaycast's observer stream for chat coverage.
Description check ✅ Passed The description is directly related to the code changes and accurately explains the observer-stream addition, lifecycle, and tests.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/pear-observer-stream

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

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 integrates a direct-to-Relaycast observer stream into the BrokerManager to capture and forward messages sent directly to Relaycast, along with comprehensive tests. The feedback suggests broadcasting live events to all active sibling sessions in multi-session environments, wrapping the lazy token minting function in a try/catch block for better error observability, and using strict undefined checks instead of falsy checks to prevent dropping empty message bodies.

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 thread src/main/broker.ts
Comment on lines +1985 to +1987
const [session] = this.sessionsForProject(projectId)
if (!session) return
this.publishBrokerEvent(sessionKeyFor(session), projectId, session.window, forwarded)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In a multi-session environment (e.g., when both a local broker and a cloud sandbox session are active for the same project), sessionsForProject(projectId) will return multiple sessions. By only destructuring the first session (const [session] = ...), the live observer stream events will only be published to the first session's window. Sibling sessions (such as the cloud sandbox session) will miss these live events on their respective windows.

To ensure all active sessions for the project receive the live events, we should record the event once in history and then dispatch the IPC event to all active windows.

    const sessions = this.sessionsForProject(projectId)
    if (sessions.length === 0) return

    const firstSession = sessions[0]
    const record = this.publishBrokerEvent(sessionKeyFor(firstSession), projectId, firstSession.window, forwarded)

    for (let i = 1; i < sessions.length; i++) {
      const sibling = sessions[i]
      const targetWindow = this.windowForSession(sessionKeyFor(sibling), sibling.window)
      if (targetWindow && !targetWindow.isDestroyed()) {
        targetWindow.webContents.send('broker:event', {
          ...forwarded,
          projectId,
          observedAt: record.timestamp,
          historyId: record.id
        })
      }
    }

Comment thread src/main/broker.ts
// the same cached/coalesced `mintObserverToken` path the "Join as
// observer" link uses, rather than one token baked in at construction
// time.
token: async () => (await this.mintObserverToken(projectId)).token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If mintObserverToken rejects (e.g., due to network issues, broker timeouts, or version mismatches), the error will propagate up. Wrapping this call in a try/catch block to log the failure to console.error ensures better observability and prevents potential unhandled promise rejections in the Electron main process.

      token: async () => {
        try {
          return (await this.mintObserverToken(projectId)).token
        } catch (err) {
          console.error(`[broker] Failed to mint observer token for project ${projectId}:`, err)
          throw err
        }
      },

Comment thread src/main/broker.ts
const id = typeof raw.id === 'string' ? raw.id : undefined
const from = typeof raw.agentName === 'string' ? raw.agentName : undefined
const body = typeof raw.text === 'string' ? raw.text : undefined
if (!id || !from || !body) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using falsy checks (!id || !from || !body) can lead to unexpected bugs if any of these fields are empty strings (""). For example, if an agent sends an empty message body, body will be "", which is falsy, causing the entire message to be dropped. It is safer to strictly check for undefined to ensure robust handling of empty strings.

  if (id === undefined || from === undefined || body === undefined) return null

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/broker.ts (1)

4418-4443: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Stop residual observer streams even when the session is already gone.

Line 4419 returns before the observer teardown check, so shutdown()’s no-live-session cleanup path cannot clear a leftover project observer stream. Move the project-scoped stream cleanup into the no-session branch too.

Suggested fix
     const session = this.sessions.get(sessionKey)
-    if (!session) return
+    if (!session) {
+      if (!this.sessionsForProject(droppedProjectId).length) {
+        this.stopObserverStream(droppedProjectId)
+      }
+      return
+    }
🤖 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 `@src/main/broker.ts` around lines 4418 - 4443, The session cleanup path in
broker.ts returns immediately when `this.sessions.get(sessionKey)` is missing,
which skips project-scoped observer stream teardown. Update the session removal
logic around the `session` lookup in `stopSession`/the shutdown cleanup path so
the `stopObserverStream(droppedProjectId)` check runs even when the session is
already gone, while still preserving the existing `unsubEvent`, `leaseTimer`,
`stopSessionFleetSidecar`, and `agentSessions` cleanup for live sessions.
🤖 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 `@src/main/broker.ts`:
- Around line 1515-1516: The local startup flow in broker.ts currently waits for
ensureLocalFleetSidecar() before opening the direct Relaycast observer stream,
which can miss messages during the sidecar timeout window. Update the startup
ordering in the existing-session and reused-existing-broker paths so the session
is registered and ensureObserverStream(normalizedProjectId) is called before
awaiting ensureLocalFleetSidecar(existing), keeping the same sequencing wherever
those paths appear.

---

Outside diff comments:
In `@src/main/broker.ts`:
- Around line 4418-4443: The session cleanup path in broker.ts returns
immediately when `this.sessions.get(sessionKey)` is missing, which skips
project-scoped observer stream teardown. Update the session removal logic around
the `session` lookup in `stopSession`/the shutdown cleanup path so the
`stopObserverStream(droppedProjectId)` check runs even when the session is
already gone, while still preserving the existing `unsubEvent`, `leaseTimer`,
`stopSessionFleetSidecar`, and `agentSessions` cleanup for live sessions.
🪄 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: 98aaade0-f3f4-4f00-8f9b-2c99b8bf1ee7

📥 Commits

Reviewing files that changed from the base of the PR and between b03028c and 9da2662.

📒 Files selected for processing (3)
  • src/main/broker.test.ts
  • src/main/broker.ts
  • src/renderer/src/stores/agent-store.stress.test.ts

Comment thread src/main/broker.ts
Comment on lines 1515 to +1516
await this.ensureLocalFleetSidecar(existing)
this.ensureObserverStream(normalizedProjectId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Start the observer stream before waiting on the fleet sidecar.

These local startup paths wait for ensureLocalFleetSidecar() before opening the direct Relaycast observer stream. Since sidecar registration can wait until its timeout, messages posted directly to Relaycast during that window are still missed. Register the session, then call ensureObserverStream() before the sidecar await.

Suggested ordering
-      await this.ensureLocalFleetSidecar(session)
       this.ensureObserverStream(normalizedProjectId)
+      await this.ensureLocalFleetSidecar(session)

Apply the same ordering in the existing-session and reused-existing-broker paths.

Also applies to: 1565-1568, 1637-1638

🤖 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 `@src/main/broker.ts` around lines 1515 - 1516, The local startup flow in
broker.ts currently waits for ensureLocalFleetSidecar() before opening the
direct Relaycast observer stream, which can miss messages during the sidecar
timeout window. Update the startup ordering in the existing-session and
reused-existing-broker paths so the session is registered and
ensureObserverStream(normalizedProjectId) is called before awaiting
ensureLocalFleetSidecar(existing), keeping the same sequencing wherever those
paths appear.

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 3 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="src/main/broker.ts">

<violation number="1" location="src/main/broker.ts:735">
P2: The falsy check `!body` will drop messages where an agent sends an empty text body (`""`), since empty string is falsy. While an empty `id` or `from` is arguably always invalid, an empty message body is a legitimate edge case (e.g., an attachment-only message or a whitespace-trimmed message). Consider using strict `undefined` checks instead:

```ts
if (id === undefined || from === undefined || body === undefined) return null
```</violation>

<violation number="2" location="src/main/broker.ts:1516">
P2: The observer stream is opened after `await this.ensureLocalFleetSidecar(session)`, which can block until its timeout. Messages arriving on Relaycast during the sidecar startup window will be missed since the observer stream isn't yet connected. Since `ensureObserverStream` is non-blocking (fires off a WS connection), calling it before the sidecar await would reduce the window of missed coverage.

This same ordering appears in all four session-start paths.</violation>

<violation number="3" location="src/main/broker.ts:1985">
P1: In a multi-session scenario (e.g., both a local broker and a cloud sandbox active for the same project), `sessionsForProject(projectId)` can return multiple sessions, but only the first one is destructured here. Observer stream events will only be dispatched to that first session's window — sibling sessions will miss live messages entirely.

Since the observer stream is intentionally project-scoped and shared across session types (as noted in the `observerStreams` map comment), the dispatch should iterate all sessions for the project, publishing to each active window.</violation>
</file>

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

Re-trigger cubic

Comment thread src/main/broker.ts
Comment on lines +1985 to +1987
const [session] = this.sessionsForProject(projectId)
if (!session) return
this.publishBrokerEvent(sessionKeyFor(session), projectId, session.window, forwarded)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: In a multi-session scenario (e.g., both a local broker and a cloud sandbox active for the same project), sessionsForProject(projectId) can return multiple sessions, but only the first one is destructured here. Observer stream events will only be dispatched to that first session's window — sibling sessions will miss live messages entirely.

Since the observer stream is intentionally project-scoped and shared across session types (as noted in the observerStreams map comment), the dispatch should iterate all sessions for the project, publishing to each active window.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/broker.ts, line 1985:

<comment>In a multi-session scenario (e.g., both a local broker and a cloud sandbox active for the same project), `sessionsForProject(projectId)` can return multiple sessions, but only the first one is destructured here. Observer stream events will only be dispatched to that first session's window — sibling sessions will miss live messages entirely.

Since the observer stream is intentionally project-scoped and shared across session types (as noted in the `observerStreams` map comment), the dispatch should iterate all sessions for the project, publishing to each active window.</comment>

<file context>
@@ -1830,6 +1940,61 @@ export class BrokerManager {
+    const forwarded = this.validateIngressBrokerEvent(payload as unknown as BrokerEvent)
+    if (!forwarded) return
+
+    const [session] = this.sessionsForProject(projectId)
+    if (!session) return
+    this.publishBrokerEvent(sessionKeyFor(session), projectId, session.window, forwarded)
</file context>
Suggested change
const [session] = this.sessionsForProject(projectId)
if (!session) return
this.publishBrokerEvent(sessionKeyFor(session), projectId, session.window, forwarded)
const sessions = this.sessionsForProject(projectId)
if (sessions.length === 0) return
for (const session of sessions) {
this.publishBrokerEvent(sessionKeyFor(session), projectId, session.window, forwarded)
}

Comment thread src/main/broker.ts
await this.syncChannels(normalizedProjectId, nextChannels)
await this.refreshEventStream(normalizedProjectId, 'existing-session-start', win)
await this.ensureLocalFleetSidecar(existing)
this.ensureObserverStream(normalizedProjectId)

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: The observer stream is opened after await this.ensureLocalFleetSidecar(session), which can block until its timeout. Messages arriving on Relaycast during the sidecar startup window will be missed since the observer stream isn't yet connected. Since ensureObserverStream is non-blocking (fires off a WS connection), calling it before the sidecar await would reduce the window of missed coverage.

This same ordering appears in all four session-start paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/broker.ts, line 1516:

<comment>The observer stream is opened after `await this.ensureLocalFleetSidecar(session)`, which can block until its timeout. Messages arriving on Relaycast during the sidecar startup window will be missed since the observer stream isn't yet connected. Since `ensureObserverStream` is non-blocking (fires off a WS connection), calling it before the sidecar await would reduce the window of missed coverage.

This same ordering appears in all four session-start paths.</comment>

<file context>
@@ -1407,6 +1513,7 @@ export class BrokerManager {
       await this.syncChannels(normalizedProjectId, nextChannels)
       await this.refreshEventStream(normalizedProjectId, 'existing-session-start', win)
       await this.ensureLocalFleetSidecar(existing)
+      this.ensureObserverStream(normalizedProjectId)
       this.sendStatus(normalizedProjectId, 'connected')
       return false
</file context>

Comment thread src/main/broker.ts
const id = typeof raw.id === 'string' ? raw.id : undefined
const from = typeof raw.agentName === 'string' ? raw.agentName : undefined
const body = typeof raw.text === 'string' ? raw.text : undefined
if (!id || !from || !body) return null

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: The falsy check !body will drop messages where an agent sends an empty text body (""), since empty string is falsy. While an empty id or from is arguably always invalid, an empty message body is a legitimate edge case (e.g., an attachment-only message or a whitespace-trimmed message). Consider using strict undefined checks instead:

if (id === undefined || from === undefined || body === undefined) return null
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main/broker.ts, line 735:

<comment>The falsy check `!body` will drop messages where an agent sends an empty text body (`""`), since empty string is falsy. While an empty `id` or `from` is arguably always invalid, an empty message body is a legitimate edge case (e.g., an attachment-only message or a whitespace-trimmed message). Consider using strict `undefined` checks instead:

```ts
if (id === undefined || from === undefined || body === undefined) return null
```</comment>

<file context>
@@ -672,6 +673,104 @@ function normalizeRelayMessageForChat(
+  const id = typeof raw.id === 'string' ? raw.id : undefined
+  const from = typeof raw.agentName === 'string' ? raw.agentName : undefined
+  const body = typeof raw.text === 'string' ? raw.text : undefined
+  if (!id || !from || !body) return null
+  return { id, from, body }
+}
</file context>

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