From 925325e3dcd629fb5296b761cdccf38283a19936 Mon Sep 17 00:00:00 2001 From: Chad Arimura Date: Thu, 20 Aug 2026 09:17:53 -0700 Subject: [PATCH] fix(desktop): include relay agents in DM activity surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay-discovered agent's declared channel scope is built from its bot-role channel memberships (kind:39002), and a DM roster can never carry the bot role: the relay hardcodes every DM participant to the plain member role at creation, and no DM member is elevated enough to change it. getChannelAgentSessionAgents therefore excluded relay agents from every DM they participate in — the declared-channel check can never match a DM and the membership fallback only applies to agents with no declared scope. The exclusion hid live agent activity twice over in DMs: the composer activity bar renders nothing when the working agent is missing from its channel-scoped agents list, and useChannelActivityTyping classifies the agent's kind:20002 typing events as human typing, so a DM shows "agent is typing..." while the same agent's turn renders the full live activity feed in a stream channel. Managed agents were unaffected (their branch already checks membership), which made BYO relay agents second-class in DMs only. In DM channels, relay agents now fall back to DM membership, with the channel's participant roster covering the window before the members query resolves. Stream and forum scoping is unchanged. Testing: new useChannelAgentSessions.test.mjs covers room scoping (unchanged), DM inclusion via members and via the participant fallback, and non-participant exclusion. Full desktop suite passes (5076/5076) and tsc --noEmit is clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QBPvBmgHKc5QpHPKCHhkRN --- .../ui/useChannelAgentSessions.test.mjs | 156 ++++++++++++++++++ .../channels/ui/useChannelAgentSessions.ts | 18 ++ 2 files changed, 174 insertions(+) create mode 100644 desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs new file mode 100644 index 00000000000..b8b61fd83dc --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getChannelAgentSessionAgents } from "./useChannelAgentSessions.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); +const OTHER_AGENT = "c".repeat(64); +const ROOM_ID = "11111111-1111-4111-8111-111111111111"; +const DM_ID = "22222222-2222-4222-8222-222222222222"; + +function makeChannel(overrides = {}) { + return { + id: ROOM_ID, + name: "ops", + channelType: "stream", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [OWNER, AGENT], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +function makeDm(overrides = {}) { + return makeChannel({ + id: DM_ID, + name: "", + channelType: "dm", + participants: ["Owner", "Agent"], + participantPubkeys: [OWNER, AGENT], + ...overrides, + }); +} + +function member(pubkey, role) { + return { + pubkey, + role, + isAgent: role === "bot", + joinedAt: "2026-08-01T00:00:00Z", + displayName: null, + }; +} + +function relayAgent(overrides = {}) { + return { + pubkey: AGENT, + name: "ops-agent", + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + channelIds: [ROOM_ID], + channels: [], + ...overrides, + }; +} + +test("relay agent stays scoped to its declared bot-role channel in rooms", () => { + const agents = getChannelAgentSessionAgents({ + activeChannel: makeChannel(), + activeChannelId: ROOM_ID, + agents: [relayAgent()], + channelMembers: [member(OWNER, "owner"), member(AGENT, "bot")], + }); + + assert.deepEqual( + agents.map((agent) => agent.pubkey), + [AGENT], + ); +}); + +test("relay agent with declared scope is excluded from undeclared rooms", () => { + const otherRoom = makeChannel({ + id: "33333333-3333-4333-8333-333333333333", + name: "elsewhere", + }); + const agents = getChannelAgentSessionAgents({ + activeChannel: otherRoom, + activeChannelId: otherRoom.id, + agents: [relayAgent()], + channelMembers: [member(OWNER, "owner"), member(AGENT, "member")], + }); + + assert.deepEqual(agents, []); +}); + +// DM rosters cannot carry the bot role — the relay hardcodes DM participants +// to `member` and no DM member is elevated enough to change a role — so an +// agent's declared bot-role channel scope must not exclude it from a DM it +// participates in. Without this, the agent's live observer activity hides +// behind the human typing row in every DM. +test("relay agent with declared scope is included in a DM it participates in", () => { + const agents = getChannelAgentSessionAgents({ + activeChannel: makeDm(), + activeChannelId: DM_ID, + agents: [relayAgent()], + channelMembers: [member(OWNER, "member"), member(AGENT, "member")], + }); + + assert.deepEqual( + agents.map((agent) => agent.pubkey), + [AGENT], + ); +}); + +test("relay agent outside the DM roster stays excluded", () => { + const agents = getChannelAgentSessionAgents({ + activeChannel: makeDm(), + activeChannelId: DM_ID, + agents: [relayAgent({ pubkey: OTHER_AGENT, name: "other-agent" })], + channelMembers: [member(OWNER, "member"), member(AGENT, "member")], + }); + + assert.deepEqual(agents, []); +}); + +test("DM inclusion falls back to participant pubkeys before members load", () => { + const agents = getChannelAgentSessionAgents({ + activeChannel: makeDm(), + activeChannelId: DM_ID, + agents: [ + relayAgent(), + relayAgent({ pubkey: OTHER_AGENT, name: "other-agent" }), + ], + channelMembers: undefined, + }); + + assert.deepEqual( + agents.map((agent) => agent.pubkey), + [AGENT], + ); +}); + +test("loaded DM members are authoritative over stale participant pubkeys", () => { + const agents = getChannelAgentSessionAgents({ + activeChannel: makeDm({ participantPubkeys: [OWNER, OTHER_AGENT] }), + activeChannelId: DM_ID, + agents: [relayAgent()], + channelMembers: [member(OWNER, "member"), member(AGENT, "member")], + }); + + assert.deepEqual( + agents.map((agent) => agent.pubkey), + [AGENT], + ); +}); diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 8420c373279..c8d4230a0ba 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -154,6 +154,24 @@ export function getChannelAgentSessionAgents({ return true; } + // A DM roster can never satisfy the declared-channel check: relay agents + // declare their bot-role channels, and DM participants always hold the + // plain member role (the relay hardcodes it at DM creation and no DM + // member is elevated enough to change it). Falling through to the + // no-declared-scope membership test below would therefore exclude a relay + // agent from every DM it participates in, hiding its live activity behind + // the human typing row. Membership itself is the authoritative signal for + // DMs, with the channel's participant roster covering the window before + // the members query resolves. + if (activeChannel.channelType === "dm") { + if (memberPubkeys) { + return memberPubkeys.has(normalizedPubkey); + } + return activeChannel.participantPubkeys.some( + (pubkey) => normalizePubkey(pubkey) === normalizedPubkey, + ); + } + return ( !hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey)) );