Skip to content

Fix DM thread replies & reaction WS fanout#70

Merged
khaliqgant merged 3 commits intomainfrom
dm-routing-fixes-threads-too
Mar 9, 2026
Merged

Fix DM thread replies & reaction WS fanout#70
khaliqgant merged 3 commits intomainfrom
dm-routing-fixes-threads-too

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 7, 2026

Summary

  • DM thread replies and reactions never reached agents via WebSocket. fanoutToChannel() broadcasts to a ChannelDO, but agents subscribe to named channels — not internal dmch_* channels. Added getDmParticipantAgentIds() helper that detects DM channels and routes events through fanoutToAgents() instead, delivering directly to agent Durable Objects.
  • Hardened getDmParticipantAgentIds with try/catch so DB errors fall back to fanoutToChannel instead of crashing the response with 500 (the reply/reaction is already persisted at that point).
  • Fixed pre-existing build issues: stale types dist artifacts breaking tests, missing vitest exclude for dist/, and implicit any types in react MessageMarkdown.tsx.

Files changed

File Change
packages/server/src/routes/fanout.ts Added getDmParticipantAgentIds() helper
packages/server/src/routes/thread.ts DM-aware fanout for thread.reply
packages/server/src/routes/reaction.ts DM-aware fanout for reaction.added and reaction.removed
packages/server/src/routes/__tests__/fanout.test.ts +4 tests (DM lookup, non-DM, empty participants, DB error)
packages/server/src/routes/__tests__/thread.test.ts +3 tests (DM vs channel fanout, error fallback)
packages/server/src/routes/__tests__/reaction.test.ts +2 tests (DM vs channel fanout)
packages/types/package.json Clean build script
packages/types/vitest.config.ts Exclude dist from vitest
packages/react/src/components/MessageMarkdown.tsx Fix implicit any types

Test plan

  • All 360 server tests pass (up from 351)
  • Full monorepo build succeeds (7/7 packages)
  • Full monorepo test succeeds (13/13 tasks)
  • Manual E2E: open DM between two agents, post thread reply → verify other agent receives thread.reply WS event
  • Manual E2E: react to a DM message → verify other agent receives reaction.added WS event

🤖 Generated with Claude Code


Open with Devin

DM thread replies and reactions on DM messages never reached agents via
WebSocket because fanoutToChannel() broadcasts to a ChannelDO that DM
agents don't subscribe to. Added getDmParticipantAgentIds() helper that
detects DM channels and routes events through fanoutToAgents() instead,
delivering directly to agent Durable Objects.

Also fixes pre-existing build issues: stale types dist artifacts, missing
vitest exclude for dist/, and implicit any types in react MessageMarkdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 7, 2026

Preview deployed!

Environment URL
API https://pr70-api.relaycast.dev
Health https://pr70-api.relaycast.dev/health
Observer https://pr70-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://pr70-api.relaycast.dev --ci

Open observer dashboard

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes WebSocket delivery for DM thread replies and reactions by routing DM events directly to participant Agent Durable Objects instead of broadcasting to ChannelDOs that agents don’t subscribe to. It also includes some monorepo hygiene fixes to prevent stale dist/ artifacts and address TypeScript build/type issues.

Changes:

  • Add getDmParticipantAgentIds() and use it to DM-route thread.reply, reaction.added, and reaction.removed via fanoutToAgents().
  • Add/adjust tests to cover DM vs non-DM fanout behavior and DB-error fallback behavior.
  • Fix build/test issues related to stale dist/ artifacts and implicit any typing in the React markdown renderer.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
packages/server/src/routes/fanout.ts Adds DM participant lookup helper used to choose agent-vs-channel fanout paths
packages/server/src/routes/thread.ts Uses DM-aware fanout for thread.reply events
packages/server/src/routes/reaction.ts Uses DM-aware fanout for reaction.added / reaction.removed events
packages/server/src/routes/tests/fanout.test.ts Adds unit tests for DM participant lookup helper behaviors
packages/server/src/routes/tests/thread.test.ts Adds route tests for DM vs channel fanout selection
packages/server/src/routes/tests/reaction.test.ts Adds route tests for DM vs channel fanout selection
packages/types/package.json Cleans build output before tsc runs
packages/types/vitest.config.ts Excludes dist/** from vitest discovery
packages/react/src/components/MessageMarkdown.tsx Addresses implicit-any errors in table cell renderers
Comments suppressed due to low confidence (2)

packages/react/src/components/MessageMarkdown.tsx:476

  • Using : any here avoids the implicit-any error but removes type safety for table cell props. Prefer a concrete type (e.g., the react-markdown component prop type for td, or React.ComponentPropsWithoutRef<'td'> + node) to keep type checking effective.
    td({ children, style, node: _node, ...props }: any) {
      return (
        <td
          {...props}
          style={{ ...TABLE_CELL_STYLE, ...style }}

packages/react/src/components/MessageMarkdown.tsx:466

  • Using : any here avoids the implicit-any error but removes type safety for table header props. Prefer a concrete type (e.g., the react-markdown component prop type for th, or React.ComponentPropsWithoutRef<'th'> + node), so consumers still get proper typing.
    th({ children, style, node: _node, ...props }: any) {
      return (
        <th
          {...props}
          style={{ ...TABLE_CELL_STYLE, fontWeight: 600, ...style }}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc",
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The build script uses rm -rf, which will fail on Windows shells (cmd/PowerShell) and can make local builds non-portable. Consider replacing this with a cross-platform approach (e.g., a small Node fs.rmSync command or a portable cleanup tool) while keeping the clean build behavior.

Suggested change
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc",
"build": "node -e \"const fs=require('fs'); if(fs.existsSync('dist')) fs.rmSync('dist',{recursive:true,force:true}); if(fs.existsSync('tsconfig.tsbuildinfo')) fs.rmSync('tsconfig.tsbuildinfo',{force:true});\" && tsc",

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +213
const db = c.get('db');
const [conv] = await db
.select({ id: dmConversations.id })
.from(dmConversations)
.where(eq(dmConversations.channelId, channelId));
if (!conv) return null;
const rows = await db
.select({ agentId: dmParticipants.agentId })
.from(dmParticipants)
.where(and(eq(dmParticipants.conversationId, conv.id), isNull(dmParticipants.leftAt)));
return rows.map((r) => r.agentId);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

getDmParticipantAgentIds runs an unindexed lookup on dm_conversations.channel_id (schema only indexes workspace_id), so on workspaces with many DM conversations this becomes a full table scan on every reply/reaction. Consider adding an index on dm_conversations.channel_id (and/or channel_id, workspace_id) or short-circuiting by checking channels.channel_type via the indexed PK before querying DM tables.

Copilot uses AI. Check for mistakes.
Comment thread packages/server/src/routes/fanout.ts Outdated
Comment on lines +214 to +216
} catch {
return null;
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

The catch { return null; } in getDmParticipantAgentIds makes DM fanout failures completely silent, which will be hard to debug in production (and will drop DM WS events). Please log the error (at least warn with channelId/workspace) before falling back to null.

Copilot uses AI. Check for mistakes.
Comment thread packages/server/src/routes/thread.ts Outdated
Comment on lines +81 to +86
const dmAgentIds = await getDmParticipantAgentIds(c, idempotent.data.channel_id);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'thread.reply', eventData), 'fanout dm thread.reply');
} else {
runInBackground(c, fanoutToChannel(c, idempotent.data.channel_id, 'thread.reply', eventData), 'fanout thread.reply');
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

This adds an extra awaited DB lookup (getDmParticipantAgentIds) on the request path before scheduling background fanout, which can increase endpoint latency. Consider moving the DM lookup + conditional fanout into the runInBackground task (e.g., an async closure) so the HTTP response timing stays the same as before.

Suggested change
const dmAgentIds = await getDmParticipantAgentIds(c, idempotent.data.channel_id);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'thread.reply', eventData), 'fanout dm thread.reply');
} else {
runInBackground(c, fanoutToChannel(c, idempotent.data.channel_id, 'thread.reply', eventData), 'fanout thread.reply');
}
runInBackground(
c,
async () => {
const dmAgentIds = await getDmParticipantAgentIds(c, idempotent.data.channel_id);
if (dmAgentIds) {
const task = fanoutToAgents(c, dmAgentIds, 'thread.reply', eventData);
return task();
} else {
const task = fanoutToChannel(c, idempotent.data.channel_id, 'thread.reply', eventData);
return task();
}
},
'fanout thread.reply',
);

Copilot uses AI. Check for mistakes.
Comment thread packages/server/src/routes/reaction.ts Outdated
Comment on lines +57 to +62
const dmAgentIds = await getDmParticipantAgentIds(c, channel_id);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'reaction.added', eventData), 'fanout dm reaction.added');
} else {
runInBackground(c, fanoutToChannel(c, channel_id, 'reaction.added', eventData), 'fanout reaction.added');
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

This now awaits getDmParticipantAgentIds before scheduling background work, adding DB round-trips to the request latency. Consider wrapping the DM lookup + conditional fanout inside runInBackground so the handler can return without waiting on the DM detection query.

Suggested change
const dmAgentIds = await getDmParticipantAgentIds(c, channel_id);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'reaction.added', eventData), 'fanout dm reaction.added');
} else {
runInBackground(c, fanoutToChannel(c, channel_id, 'reaction.added', eventData), 'fanout reaction.added');
}
runInBackground(
c,
(async () => {
const dmAgentIds = await getDmParticipantAgentIds(c, channel_id);
if (dmAgentIds) {
await fanoutToAgents(c, dmAgentIds, 'reaction.added', eventData);
} else {
await fanoutToChannel(c, channel_id, 'reaction.added', eventData);
}
})(),
'fanout reaction.added',
);

Copilot uses AI. Check for mistakes.
Comment thread packages/server/src/routes/reaction.ts Outdated
Comment on lines +128 to +133
const dmAgentIds = await getDmParticipantAgentIds(c, row.channelId);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'reaction.removed', enriched), 'fanout dm reaction.removed');
} else {
runInBackground(c, fanoutToChannel(c, row.channelId, 'reaction.removed', enriched), 'fanout reaction.removed');
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

Same as the POST path: awaiting getDmParticipantAgentIds here adds avoidable latency to the DELETE request. Consider doing the DM lookup + fanout selection inside the existing background task / try block so the response isn't gated on extra DB queries.

Suggested change
const dmAgentIds = await getDmParticipantAgentIds(c, row.channelId);
if (dmAgentIds) {
runInBackground(c, fanoutToAgents(c, dmAgentIds, 'reaction.removed', enriched), 'fanout dm reaction.removed');
} else {
runInBackground(c, fanoutToChannel(c, row.channelId, 'reaction.removed', enriched), 'fanout reaction.removed');
}
runInBackground(
c,
(async () => {
try {
const dmAgentIds = await getDmParticipantAgentIds(c, row.channelId);
if (dmAgentIds) {
await fanoutToAgents(c, dmAgentIds, 'reaction.removed', enriched);
} else {
await fanoutToChannel(c, row.channelId, 'reaction.removed', enriched);
}
} catch {
// Ignore fanout failures
}
})(),
'fanout reaction.removed',
);

Copilot uses AI. Check for mistakes.
@khaliqgant khaliqgant merged commit 9872d76 into main Mar 9, 2026
4 checks passed
@khaliqgant khaliqgant deleted the dm-routing-fixes-threads-too branch March 9, 2026 12:19
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.

2 participants