From e00787c9e51295526f35edef586702381025012f Mon Sep 17 00:00:00 2001 From: Morgan G Hough Date: Tue, 10 Mar 2026 22:53:03 -0700 Subject: [PATCH] fix: prevent agent participants getting stuck in private chat (#1011, #938) Two related bugs cause private chat stages to hang indefinitely: 1. When a mediator returns shouldRespond: false (#938), no message is written to Firestore. The frontend spinner hangs for 120s because there's no signal that the mediator chose not to respond. 2. When a mediator hits maxResponses or returns readyToEndChat with an empty message (#1011), agent participants never get triggered because they only respond to MEDIATOR-type messages. No new message is written, so the agent is stuck forever. Fix: When a mediator can no longer respond (maxResponses reached or shouldRespond: false), send a system message (" has left the chat") to the private chat. This: - Stops the frontend spinner (system message sender != participant) - Triggers agent participants to act (they now also respond to SYSTEM messages, not just MEDIATOR messages) - Uses trigger log deduplication to prevent infinite loops - Moves the empty-text check after shouldRespond/readyToEndChat extraction so structured output signals aren't treated as failures Changes: - chat.agent.ts: Send "left the chat" system message when mediator can't respond; handle shouldRespond:false for mediator type; reorder empty-text check to respect structured output signals - chat.utils.ts: Add sendSystemPrivateChatMessage() utility - chat.triggers.ts: Skip mediator responses to system messages; allow agent participants to respond to system messages Co-Authored-By: Claude Opus 4.6 --- functions/src/chat/chat.agent.ts | 93 ++++++++++++++++----- functions/src/chat/chat.utils.ts | 29 +++++++ functions/src/triggers/chat.triggers.ts | 105 +++++++++++++----------- 3 files changed, 161 insertions(+), 66 deletions(-) diff --git a/functions/src/chat/chat.agent.ts b/functions/src/chat/chat.agent.ts index db10591f5..03260a6a4 100644 --- a/functions/src/chat/chat.agent.ts +++ b/functions/src/chat/chat.agent.ts @@ -30,7 +30,10 @@ import { shouldUseMessageFormat, MessageRole, } from './message_converter.utils'; -import {updateParticipantReadyToEndChat} from '../chat/chat.utils'; +import { + updateParticipantReadyToEndChat, + sendSystemPrivateChatMessage, +} from '../chat/chat.utils'; import { getExperimenterDataFromExperiment, getFirestorePublicStageChatMessages, @@ -219,6 +222,35 @@ export async function getAgentChatMessage( // Confirm that agent can send chat messages based on prompt config const chatSettings = promptConfig.chatSettings; if (!canSendAgentChatMessage(user.publicId, chatSettings, chatMessages)) { + // If a mediator in a private chat can no longer respond (e.g., maxResponses + // reached), send a system message so the frontend stops showing the spinner + // and agent participants get unblocked. (Fixes #1011) + // Use trigger log to send only once per mediator. + if ( + stage.kind === StageKind.PRIVATE_CHAT && + user.type === UserType.MEDIATOR + ) { + const leftChatLogRef = getPrivateChatTriggerLogRef( + experimentId, + participantIds[0], + stageId, + `left-chat-${user.publicId}`, + ); + const alreadySent = (await leftChatLogRef.get()).exists; + if (!alreadySent) { + await leftChatLogRef.set({timestamp: Timestamp.now()}); + await sendSystemPrivateChatMessage( + experimentId, + participantIds[0], + stageId, + { + message: `${user.name ?? 'Mediator'} has left the chat.`, + senderId: user.publicId, + profile: createParticipantProfileBase(user), + }, + ); + } + } return {message: null, success: true}; } @@ -340,15 +372,17 @@ export async function getAgentChatMessage( } } - // No text and no files = failure - if (!response.text && (!response.files || response.files.length === 0)) { + // No text and no files = failure, unless the agent explicitly chose not + // to respond or signaled readyToEndChat (empty text is expected then). + if ( + shouldRespond && + !readyToEndChat && + !response.text && + (!response.files || response.files.length === 0) + ) { return {message: null, success: false}; } - if (!shouldRespond) { - // Logic for not responding (handled below) - } - // Only if agent participant is ready to end chat if (readyToEndChat && user.type === UserType.PARTICIPANT) { // Ensure we don't end chat on the very first message @@ -368,18 +402,39 @@ export async function getAgentChatMessage( } if (!shouldRespond) { - // If agent decides not to respond in private chat, they are ready to end - if ( - stage.kind === StageKind.PRIVATE_CHAT && - user.type === UserType.PARTICIPANT && - chatMessages.length > 0 - ) { - const participantAnswerDoc = getFirestoreParticipantAnswerRef( - experimentId, - user.privateId, - stageId, - ); - await participantAnswerDoc.set({readyToEndChat: true}, {merge: true}); + if (stage.kind === StageKind.PRIVATE_CHAT && chatMessages.length > 0) { + if (user.type === UserType.PARTICIPANT) { + // Agent participant is ready to end chat + const participantAnswerDoc = getFirestoreParticipantAnswerRef( + experimentId, + user.privateId, + stageId, + ); + await participantAnswerDoc.set({readyToEndChat: true}, {merge: true}); + } else if (user.type === UserType.MEDIATOR) { + // Mediator chose not to respond — send a system message so the + // frontend spinner stops and agent participants get unblocked. (#938) + const leftChatLogRef = getPrivateChatTriggerLogRef( + experimentId, + participantIds[0], + stageId, + `left-chat-${user.publicId}`, + ); + const alreadySent = (await leftChatLogRef.get()).exists; + if (!alreadySent) { + await leftChatLogRef.set({timestamp: Timestamp.now()}); + await sendSystemPrivateChatMessage( + experimentId, + participantIds[0], + stageId, + { + message: `${user.name ?? 'Mediator'} has left the chat.`, + senderId: user.publicId, + profile: createParticipantProfileBase(user), + }, + ); + } + } } return {message: null, success: true}; } diff --git a/functions/src/chat/chat.utils.ts b/functions/src/chat/chat.utils.ts index ca5128c0f..e0b945477 100644 --- a/functions/src/chat/chat.utils.ts +++ b/functions/src/chat/chat.utils.ts @@ -22,6 +22,35 @@ import { } from '@deliberation-lab/utils'; import {updateParticipantNextStage} from '../participant.utils'; +/** Send a system message to a private chat (e.g., mediator left the chat). + * Unlike error messages, system messages are NOT ignored by chat triggers, + * so they can wake up agent participants. + */ +export async function sendSystemPrivateChatMessage( + experimentId: string, + participantId: string, + stageId: string, + config: Partial = {}, +) { + const chatMessage = createSystemChatMessage({ + ...config, + timestamp: Timestamp.now(), + }); + + const agentDocument = app + .firestore() + .collection('experiments') + .doc(experimentId) + .collection('participants') + .doc(participantId) + .collection('stageData') + .doc(stageId) + .collection('privateChats') + .doc(chatMessage.id); + + await agentDocument.set(chatMessage); +} + /** Used for private chats if model response fails. */ export async function sendErrorPrivateChatMessage( experimentId: string, diff --git a/functions/src/triggers/chat.triggers.ts b/functions/src/triggers/chat.triggers.ts index 7fc1925b2..be002d124 100644 --- a/functions/src/triggers/chat.triggers.ts +++ b/functions/src/triggers/chat.triggers.ts @@ -144,71 +144,82 @@ export const onPrivateChatMessageCreated = onDocumentCreated( ); if (!stage) return; - // Send agent mediator messages const participant = await getFirestoreParticipant( event.params.experimentId, event.params.participantId, ); if (!participant) return; - const mediators = await getFirestoreActiveMediators( - event.params.experimentId, - participant.currentCohortId, - stage.id, - true, - ); - - await Promise.all( - mediators.map(async (mediator) => { - const result = await createAgentChatMessageFromPrompt( - event.params.experimentId, - participant.currentCohortId, - [participant.privateId], - stage.id, - event.params.chatId, - mediator, - ); + // Send agent mediator responses to participant messages only. + // System messages (e.g., "mediator has left") should not trigger + // mediator responses — they are signals, not conversation turns. + if ( + message.type !== UserType.MEDIATOR && + message.type !== UserType.SYSTEM + ) { + const mediators = await getFirestoreActiveMediators( + event.params.experimentId, + participant.currentCohortId, + stage.id, + true, + ); - if (!result) { - await sendErrorPrivateChatMessage( + await Promise.all( + mediators.map(async (mediator) => { + const result = await createAgentChatMessageFromPrompt( event.params.experimentId, - participant.privateId, + participant.currentCohortId, + [participant.privateId], stage.id, - { - discussionId: message.discussionId, - message: 'Error fetching response', - type: mediator.type, - profile: createParticipantProfileBase(mediator), - senderId: mediator.publicId, - agentId: mediator.agentConfig?.agentId ?? '', - }, + event.params.chatId, + mediator, ); - } - }), - ); - // If no mediator, return error (otherwise participant may wait - // indefinitely for a response). - if (mediators.length === 0) { - await sendErrorPrivateChatMessage( - event.params.experimentId, - participant.privateId, - stage.id, - { - discussionId: message.discussionId, - message: 'No mediators found', - }, + if (!result) { + await sendErrorPrivateChatMessage( + event.params.experimentId, + participant.privateId, + stage.id, + { + discussionId: message.discussionId, + message: 'Error fetching response', + type: mediator.type, + profile: createParticipantProfileBase(mediator), + senderId: mediator.publicId, + agentId: mediator.agentConfig?.agentId ?? '', + }, + ); + } + }), ); + + // If no mediator, return error (otherwise participant may wait + // indefinitely for a response). + if (mediators.length === 0) { + await sendErrorPrivateChatMessage( + event.params.experimentId, + participant.privateId, + stage.id, + { + discussionId: message.discussionId, + message: 'No mediators found', + }, + ); + } } - // Send agent participant messages (if participant is an agent) + // Send agent participant messages (if participant is an agent). + // Agent responds to mediator messages and system messages (e.g., + // "mediator has left the chat") so it can advance stages. (#1011) if (participant.agentConfig) { - // Ensure agent only responds to mediator, not themselves - if (message.type === UserType.MEDIATOR) { + if ( + message.type === UserType.MEDIATOR || + message.type === UserType.SYSTEM + ) { await createAgentChatMessageFromPrompt( event.params.experimentId, participant.currentCohortId, - [participant.privateId], // Pass agent's own ID as array + [participant.privateId], stage.id, event.params.chatId, participant,