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)) );