From 3f511b095ea20bf169c5c42820fd80328bddf4dc Mon Sep 17 00:00:00 2001 From: Jennifer Hickey Date: Tue, 2 Jun 2026 14:18:34 -0400 Subject: [PATCH 1/3] feat: subscribe to all direct message channels in conversation support the addition of future agent DMs by subscribing to all direct channels, filtering intro msg to just eventAssistant --- __tests__/pages/assistant.test.tsx | 195 +++++++++++++++++++++++------ pages/assistant.tsx | 69 +++++----- 2 files changed, 187 insertions(+), 77 deletions(-) diff --git a/__tests__/pages/assistant.test.tsx b/__tests__/pages/assistant.test.tsx index 1696725..2130818 100644 --- a/__tests__/pages/assistant.test.tsx +++ b/__tests__/pages/assistant.test.tsx @@ -1323,82 +1323,201 @@ describe('EventAssistantRoom', () => { }); }); - describe('Jargon message routing', () => { - it('displays jargon clarification messages inline in the assistant panel when jargonFilterAgentId is set', async () => { - const conversationWithJargon = { + describe('Multi-agent channel subscription and message fetching', () => { + it('subscribes to direct channels for all agents in the conversation', async () => { + (createConversationFromData as jest.Mock).mockResolvedValue({ agents: [ { id: 'agent-123', agentType: 'eventAssistant' }, { id: 'jargon-agent-456', agentType: 'jargonFilterAgent' }, + { id: 'future-agent-789', agentType: 'someNewAgent' }, ], type: { name: 'eventAssistant' }, + }); + + (RetrieveData as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); + return Promise.resolve([]); + }); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(mockSocket.emit).toHaveBeenCalledWith( + 'conversation:join', + expect.objectContaining({ + channels: expect.arrayContaining([ + { name: 'direct-user-123-agent-123', passcode: null, direct: true }, + { name: 'direct-user-123-jargon-agent-456', passcode: null, direct: true }, + { name: 'direct-user-123-future-agent-789', passcode: null, direct: true }, + ]), + }), + ); + }); + }); + + it('fetches messages from all agent direct channels', async () => { + (createConversationFromData as jest.Mock).mockResolvedValue({ + agents: [ + { id: 'agent-123', agentType: 'eventAssistant' }, + { id: 'jargon-agent-456', agentType: 'jargonFilterAgent' }, + ], + type: { name: 'eventAssistant' }, + }); + + (RetrieveData as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); + return Promise.resolve([]); + }); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(RetrieveData).toHaveBeenCalledWith( + 'messages/test-conversation-id?channel=direct-user-123-agent-123', + 'mock-access-token', + ); + expect(RetrieveData).toHaveBeenCalledWith( + 'messages/test-conversation-id?channel=direct-user-123-jargon-agent-456', + 'mock-access-token', + ); + }); + }); + + it('displays messages from any agent direct channel in the assistant panel', async () => { + const secondaryAgentMessage = { + id: 'secondary-msg-1', + body: { type: 'jargon_clarification', text: 'An SLO is a reliability target.', sourceText: 'Our SLOs...' }, + bodyType: 'json', + fromAgent: true, + channels: ['direct-user-123-jargon-agent-456'], + pseudonym: 'Jargon Filter', + createdAt: '2024-01-01T10:00:00Z', + pause: false, + visible: true, + upVotes: [], + downVotes: [], }; - (createConversationFromData as jest.Mock).mockResolvedValue(conversationWithJargon); + + (createConversationFromData as jest.Mock).mockResolvedValue({ + agents: [ + { id: 'agent-123', agentType: 'eventAssistant' }, + { id: 'jargon-agent-456', agentType: 'jargonFilterAgent' }, + ], + type: { name: 'eventAssistant' }, + }); (RetrieveData as jest.Mock).mockImplementation((path: string) => { - if (path.startsWith('conversations/')) { - return Promise.resolve(conversationWithJargon); - } else if (path.includes('users/user/') && path.includes('/preferences')) { - return Promise.resolve({ jargonClarification: true }); - } else if (path.startsWith('messages/')) { + if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); + if (path.includes('jargon-agent-456')) return Promise.resolve([secondaryAgentMessage]); return Promise.resolve([]); - } - return Promise.resolve({}); }); + const user = userEvent.setup(); + await act(async () => { render(); }); - // Wait for jargonFilterAgentId to be set from conversation data + await waitFor(() => expect(screen.getAllByLabelText('Berkie').length).toBeGreaterThan(0)); + await user.click(screen.getAllByLabelText('Berkie')[0]); + await waitFor(() => { - expect(mockSocket.on).toHaveBeenCalledWith('message:new', expect.any(Function)); + expect(screen.getByText('An SLO is a reliability target.')).toBeInTheDocument(); + }); + }); + + it('displays real-time messages from any agent direct channel in the assistant panel', async () => { + (createConversationFromData as jest.Mock).mockResolvedValue({ + agents: [ + { id: 'agent-123', agentType: 'eventAssistant' }, + { id: 'jargon-agent-456', agentType: 'jargonFilterAgent' }, + ], + type: { name: 'eventAssistant' }, }); - // Retrieve the most recently registered message:new handler — it has jargonFilterAgentId in its closure + (RetrieveData as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); + return Promise.resolve([]); + }); + + await act(async () => { + render(); + }); + + await waitFor(() => expect(mockSocket.on).toHaveBeenCalledWith('message:new', expect.any(Function))); + const messageHandler: Function = mockSocket.on.mock.calls .filter(([event]: [string]) => event === 'message:new') .map(([, handler]: [string, Function]) => handler) .at(-1)!; - expect(messageHandler).toBeDefined(); - expect(typeof messageHandler).toBe('function'); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getAllByLabelText('Berkie').length).toBeGreaterThan(0)); + await user.click(screen.getAllByLabelText('Berkie')[0]); - // Simulate a jargon clarification message arriving on the jargon filter's direct channel - const jargonMessage = { + act(() => { + messageHandler({ id: 'msg-jargon-1', - body: { - type: 'jargon_clarification', - text: 'An SLO is a reliability target.', - sourceText: 'Our SLOs...', - }, + body: { type: 'jargon_clarification', text: 'An SLO is a reliability target.', sourceText: 'Our SLOs...' }, bodyType: 'json', fromAgent: true, channels: ['direct-user-123-jargon-agent-456'], pseudonym: 'Jargon Filter Agent', - pseudonymId: 'jargon-agent-456', - conversation: 'test-conversation-id', pause: false, visible: true, upVotes: [], downVotes: [], - }; + }); + }); - // Switch to assistant tab before receiving the message await waitFor(() => { - const assistantTabs = screen.queryAllByLabelText(/Berkie|Assistant/i); - expect(assistantTabs.length).toBeGreaterThan(0); + expect(screen.getByText('An SLO is a reliability target.')).toBeInTheDocument(); }); + }); - const assistantTab = screen.getAllByLabelText(/Berkie|Assistant/i)[0]; - await userEvent.click(assistantTab); + it('routes replies to the channel of the parent message, not the primary agent channel', async () => { + const secondaryAgentMessage = { + id: 'jargon-msg-1', + body: 'A clarification from jargon agent', + fromAgent: true, + channels: ['direct-user-123-jargon-agent-456'], + pseudonym: 'Jargon Filter', + createdAt: '2024-01-01T10:00:00Z', + pause: false, + visible: true, + upVotes: [], + downVotes: [], + }; - act(() => { - messageHandler(jargonMessage); + (createConversationFromData as jest.Mock).mockResolvedValue({ + agents: [ + { id: 'agent-123', agentType: 'eventAssistant' }, + { id: 'jargon-agent-456', agentType: 'jargonFilterAgent' }, + ], + type: { name: 'eventAssistant' }, }); - // Verify the jargon clarification content appears inline in the assistant panel + (RetrieveData as jest.Mock).mockImplementation((path: string) => { + if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); + if (path.includes('jargon-agent-456')) return Promise.resolve([secondaryAgentMessage]); + return Promise.resolve([]); + }); + + const user = userEvent.setup(); + + await act(async () => { + render(); + }); + + await waitFor(() => expect(screen.getAllByLabelText('Berkie').length).toBeGreaterThan(0)); + await user.click(screen.getAllByLabelText('Berkie')[0]); + await waitFor(() => { - expect(screen.getByText('An SLO is a reliability target.')).toBeInTheDocument(); + expect(screen.getByText('A clarification from jargon agent')).toBeInTheDocument(); }); }); }); @@ -1752,7 +1871,7 @@ describe('EventAssistantRoom', () => { body: 'Welcome to the event!', pseudonym: 'Berkie', fromAgent: true, - channels: [], + channels: ['direct-user-123-agent-123'], createdAt: '2024-01-01T09:59:00Z', conversation: 'test-conversation-id', pause: false, @@ -1972,7 +2091,7 @@ describe('EventAssistantRoom', () => { body: 'Intro message', pseudonym: 'Berkie', fromAgent: true, - channels: [], + channels: ['direct-user-123-agent-123'], createdAt: '2024-01-01T09:59:00Z', }; @@ -2080,7 +2199,7 @@ describe('EventAssistantRoom', () => { body: 'Intro message', pseudonym: 'Berkie', fromAgent: true, - channels: [], + channels: ['direct-user-123-agent-123'], createdAt: '2024-01-01T09:59:00Z', }; diff --git a/pages/assistant.tsx b/pages/assistant.tsx index 42e72ee..25a6585 100644 --- a/pages/assistant.tsx +++ b/pages/assistant.tsx @@ -101,7 +101,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { const assistantIntroRef = useRef([]); const [agentId, setAgentId] = useState(null); const [agentActive, setAgentActive] = useState(true); - const [jargonFilterAgentId, setJargonFilterAgentId] = useState(null); + const [agentIds, setAgentIds] = useState([]); const [conversationFeatures, setConversationFeatures] = useState<{ name: string; enabled?: boolean }[]>([]); const conversationType = useConversationType(); const setConversationType = useSetConversationType(); @@ -220,7 +220,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { socket.off('message:new', messageHandler); socket.off('resources:updated', resourcesUpdatedHandler); }; - }, [socket, jargonFilterAgentId]); + }, [socket]); // Keep activeTabRef in sync with activeTab state useEffect(() => { @@ -315,12 +315,8 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { setAgentActive(false); } - const jargonAgent = conversation.agents.find( - (agent: components['schemas']['Agent']) => agent.agentType === 'jargonFilterAgent', - ); - if (jargonAgent) { - setJargonFilterAgentId(jargonAgent.id!); - } + const ids = conversation.agents.map((agent: components['schemas']['Agent']) => agent.id!); + setAgentIds(ids); } catch (error) { console.error('Error fetching conversation data:', error); setLocalError('Failed to fetch conversation data.'); @@ -342,8 +338,12 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { return; } - const agentChannels = agentId - ? buildDirectChannels(userId, [{ agentId }, ...(jargonFilterAgentId ? [{ agentId: jargonFilterAgentId }] : [])]) + const agentChannels = + agentIds.length > 0 + ? buildDirectChannels( + userId, + agentIds.map((id) => ({ agentId: id })), + ) : []; const channels: components['schemas']['Channel'][] = [...agentChannels]; @@ -378,8 +378,10 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { const intros: PseudonymousMessage[] = response?.intros ?? []; chatIntroRef.current = intros.filter((m) => Array.isArray(m.channels) && m.channels.includes('chat')); - // Any intro not in chat belongs to the direct (assistant) channel. - assistantIntroRef.current = intros.filter((m) => !Array.isArray(m.channels) || !m.channels.includes('chat')); + // Only intros on the eventAssistant's direct channel. + assistantIntroRef.current = intros.filter( + (m) => Array.isArray(m.channels) && m.channels.some((c) => c.includes(`-${agentId}`)), + ); // Only push intros into state on the very first join. Re-joins // must not add them again — the initial fetch effects will prepend @@ -409,38 +411,26 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { return () => { socket.off('connect', joinConversation); }; - }, [socket, agentId, agentActive, jargonFilterAgentId, userId, chatPasscode, router.query.conversationId]); + }, [socket, agentId, agentActive, agentIds, userId, chatPasscode, router.query.conversationId]); /** - * Helper function to fetch all assistant messages (direct + jargon filter) + * Helper function to fetch all assistant messages across all agent direct channels * and their threaded replies */ const fetchAllAssistantMessages = useCallback(async (): Promise => { if (!userId || !agentId || !router.query.conversationId) return; try { - const directChannelName = `direct-${userId}-${agentId}`; - const assistantMessages = await RetrieveData( - `messages/${router.query.conversationId}?channel=${directChannelName}`, - Api.get().getAccessToken(), + const allFetched = await Promise.all( + agentIds.map((id) => + RetrieveData(`messages/${router.query.conversationId}?channel=direct-${userId}-${id}`, Api.get().getAccessToken()), + ), ); // Filter intro messages from older conversations where intros were persisted - let allMessages = (Array.isArray(assistantMessages) ? assistantMessages : []).filter( - (m) => parseMessageBody(m.body)?.type !== 'intro', - ); - - // Fetch jargon filter agent's messages, if enabled - if (jargonFilterAgentId) { - const jargonChannelName = `direct-${userId}-${jargonFilterAgentId}`; - const jargonMessages = await RetrieveData( - `messages/${router.query.conversationId}?channel=${jargonChannelName}`, - Api.get().getAccessToken(), - ); - if (Array.isArray(jargonMessages)) { - allMessages = [...allMessages, ...jargonMessages]; - } - } + let allMessages = allFetched + .flatMap((result) => (Array.isArray(result) ? result : [])) + .filter((m) => parseMessageBody(m.body)?.type !== 'intro'); // Sort all messages by creation time allMessages.sort((a, b) => new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime()); @@ -452,7 +442,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { } catch (error) { console.error('Error fetching assistant messages:', error); } - }, [userId, agentId, jargonFilterAgentId, router.query.conversationId]); + }, [userId, agentId, agentIds, router.query.conversationId]); /** * Helper function to track reply count changes and identify unread messages @@ -617,12 +607,13 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { let channels = effectiveTab === 'chat' ? [{ name: 'chat', passcode: chatPasscode }] : [{ name: `direct-${userId}-${agentId}` }]; - // If replying to a message in assistant tab, check if parent came from jargon filter agent - // and use that channel instead - if (effectiveTab !== 'chat' && parentMessageId && jargonFilterAgentId) { + // If replying to a message in assistant tab, route to whichever direct channel + // the parent message was on (supports any agent, not just the primary one) + if (effectiveTab !== 'chat' && parentMessageId) { const parentMessage = assistantMessages.find((m) => m.id === parentMessageId); - if (parentMessage?.channels?.some((c) => c.includes(jargonFilterAgentId))) { - channels = [{ name: `direct-${userId}-${jargonFilterAgentId}` }]; + const parentDirectChannel = parentMessage?.channels?.find((c) => c.startsWith('direct-') && !c.includes(agentId!)); + if (parentDirectChannel) { + channels = [{ name: parentDirectChannel }]; } } From 779a73c29b528bf4f508b603b2c367f73c254223 Mon Sep 17 00:00:00 2001 From: Jennifer Hickey Date: Tue, 2 Jun 2026 14:21:36 -0400 Subject: [PATCH 2/3] fix: duplicate websocket conversation join on assistant page load only emit join event when socket has fully connected to avoid duplicate send mid-handshake --- __tests__/pages/assistant.test.tsx | 21 +++++++++++---------- pages/assistant.tsx | 13 ++++++++----- utils/useSessionJoin.ts | 7 +++++++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/__tests__/pages/assistant.test.tsx b/__tests__/pages/assistant.test.tsx index 2130818..99c96dc 100644 --- a/__tests__/pages/assistant.test.tsx +++ b/__tests__/pages/assistant.test.tsx @@ -28,6 +28,7 @@ const mockSocket = { emit: jest.fn(), auth: { token: 'mock-token' }, hasListeners: jest.fn(() => false), + connected: true, }; jest.mock('socket.io-client', () => ({ @@ -1413,7 +1414,7 @@ describe('EventAssistantRoom', () => { (RetrieveData as jest.Mock).mockImplementation((path: string) => { if (path.startsWith('conversations/')) return Promise.resolve({ agents: [] }); if (path.includes('jargon-agent-456')) return Promise.resolve([secondaryAgentMessage]); - return Promise.resolve([]); + return Promise.resolve([]); }); const user = userEvent.setup(); @@ -1461,16 +1462,16 @@ describe('EventAssistantRoom', () => { act(() => { messageHandler({ - id: 'msg-jargon-1', + id: 'msg-jargon-1', body: { type: 'jargon_clarification', text: 'An SLO is a reliability target.', sourceText: 'Our SLOs...' }, - bodyType: 'json', - fromAgent: true, - channels: ['direct-user-123-jargon-agent-456'], - pseudonym: 'Jargon Filter Agent', - pause: false, - visible: true, - upVotes: [], - downVotes: [], + bodyType: 'json', + fromAgent: true, + channels: ['direct-user-123-jargon-agent-456'], + pseudonym: 'Jargon Filter Agent', + pause: false, + visible: true, + upVotes: [], + downVotes: [], }); }); diff --git a/pages/assistant.tsx b/pages/assistant.tsx index 25a6585..284e8f7 100644 --- a/pages/assistant.tsx +++ b/pages/assistant.tsx @@ -344,7 +344,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { userId, agentIds.map((id) => ({ agentId: id })), ) - : []; + : []; const channels: components['schemas']['Channel'][] = [...agentChannels]; if (chatPasscode) { @@ -401,13 +401,16 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { }, ); }; - - // Initial join - joinConversation(); - // Re-join on every subsequent reconnection (e.g. after token refresh) socket.on('connect', joinConversation); + // Only join immediately if the socket is already connected — otherwise + // let the connect event fire the first join to avoid a duplicate join + // when the socket is mid-handshake or reconnecting after a token refresh. + if (socket.connected) { + joinConversation(); + } + return () => { socket.off('connect', joinConversation); }; diff --git a/utils/useSessionJoin.ts b/utils/useSessionJoin.ts index ccea8ef..771b989 100644 --- a/utils/useSessionJoin.ts +++ b/utils/useSessionJoin.ts @@ -174,6 +174,13 @@ export function useSessionJoin( socket.on('disconnect', handleDisconnect); socket.on('connect_error', handleConnectError); + // If the socket is already connected when this effect runs (e.g. StrictMode + // remount or token-refresh reconnect already completed), sync state immediately + // since the connect event won't fire again for the current connection. + if (socket.connected) { + handleConnect(); + } + return () => { socket.off('error', handleError); socket.off('connect', handleConnect); From 9f5a069371e3b27946d13f9ae31288a0ad42d311 Mon Sep 17 00:00:00 2001 From: Jennifer Hickey Date: Fri, 5 Jun 2026 08:50:16 -0400 Subject: [PATCH 3/3] fix: prevent duplicate direct channel creation in strict mode when useEffects run twice, has potential to create duplicate direct channels --- pages/assistant.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pages/assistant.tsx b/pages/assistant.tsx index 284e8f7..a56482e 100644 --- a/pages/assistant.tsx +++ b/pages/assistant.tsx @@ -99,6 +99,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { // reconnect re-fetches can always prepend them without stale-closure issues. const chatIntroRef = useRef([]); const assistantIntroRef = useRef([]); + const hasJoinedConvRef = useRef(false); const [agentId, setAgentId] = useState(null); const [agentActive, setAgentActive] = useState(true); const [agentIds, setAgentIds] = useState([]); @@ -361,6 +362,8 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { } const joinConversation = () => { + if (hasJoinedConvRef.current) return; + hasJoinedConvRef.current = true; console.log('Joining conversation'); // Always read the current token so re-joins after a refresh use the // new token rather than the one captured at socket-creation time. @@ -402,7 +405,11 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { ); }; // Re-join on every subsequent reconnection (e.g. after token refresh) - socket.on('connect', joinConversation); + const onConnect = () => { + hasJoinedConvRef.current = false; + joinConversation(); + }; + socket.on('connect', onConnect); // Only join immediately if the socket is already connected — otherwise // let the connect event fire the first join to avoid a duplicate join @@ -412,7 +419,7 @@ function EventAssistantRoom({ authType: _authType }: { authType: AuthType }) { } return () => { - socket.off('connect', joinConversation); + socket.off('connect', onConnect); }; }, [socket, agentId, agentActive, agentIds, userId, chatPasscode, router.query.conversationId]);