Skip to content

Commit 95eaeab

Browse files
m9hclaude
andcommitted
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 ("<name> 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 <noreply@anthropic.com>
1 parent 10889a4 commit 95eaeab

3 files changed

Lines changed: 161 additions & 66 deletions

File tree

functions/src/chat/chat.agent.ts

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ import {
3030
shouldUseMessageFormat,
3131
MessageRole,
3232
} from './message_converter.utils';
33-
import {updateParticipantReadyToEndChat} from '../chat/chat.utils';
33+
import {
34+
updateParticipantReadyToEndChat,
35+
sendSystemPrivateChatMessage,
36+
} from '../chat/chat.utils';
3437
import {
3538
getExperimenterDataFromExperiment,
3639
getFirestorePublicStageChatMessages,
@@ -219,6 +222,35 @@ export async function getAgentChatMessage(
219222
// Confirm that agent can send chat messages based on prompt config
220223
const chatSettings = promptConfig.chatSettings;
221224
if (!canSendAgentChatMessage(user.publicId, chatSettings, chatMessages)) {
225+
// If a mediator in a private chat can no longer respond (e.g., maxResponses
226+
// reached), send a system message so the frontend stops showing the spinner
227+
// and agent participants get unblocked. (Fixes #1011)
228+
// Use trigger log to send only once per mediator.
229+
if (
230+
stage.kind === StageKind.PRIVATE_CHAT &&
231+
user.type === UserType.MEDIATOR
232+
) {
233+
const leftChatLogRef = getPrivateChatTriggerLogRef(
234+
experimentId,
235+
participantIds[0],
236+
stageId,
237+
`left-chat-${user.publicId}`,
238+
);
239+
const alreadySent = (await leftChatLogRef.get()).exists;
240+
if (!alreadySent) {
241+
await leftChatLogRef.set({timestamp: Timestamp.now()});
242+
await sendSystemPrivateChatMessage(
243+
experimentId,
244+
participantIds[0],
245+
stageId,
246+
{
247+
message: `${user.name ?? 'Mediator'} has left the chat.`,
248+
senderId: user.publicId,
249+
profile: createParticipantProfileBase(user),
250+
},
251+
);
252+
}
253+
}
222254
return {message: null, success: true};
223255
}
224256

@@ -340,15 +372,17 @@ export async function getAgentChatMessage(
340372
}
341373
}
342374

343-
// No text and no files = failure
344-
if (!response.text && (!response.files || response.files.length === 0)) {
375+
// No text and no files = failure, unless the agent explicitly chose not
376+
// to respond or signaled readyToEndChat (empty text is expected then).
377+
if (
378+
shouldRespond &&
379+
!readyToEndChat &&
380+
!response.text &&
381+
(!response.files || response.files.length === 0)
382+
) {
345383
return {message: null, success: false};
346384
}
347385

348-
if (!shouldRespond) {
349-
// Logic for not responding (handled below)
350-
}
351-
352386
// Only if agent participant is ready to end chat
353387
if (readyToEndChat && user.type === UserType.PARTICIPANT) {
354388
// Ensure we don't end chat on the very first message
@@ -368,18 +402,39 @@ export async function getAgentChatMessage(
368402
}
369403

370404
if (!shouldRespond) {
371-
// If agent decides not to respond in private chat, they are ready to end
372-
if (
373-
stage.kind === StageKind.PRIVATE_CHAT &&
374-
user.type === UserType.PARTICIPANT &&
375-
chatMessages.length > 0
376-
) {
377-
const participantAnswerDoc = getFirestoreParticipantAnswerRef(
378-
experimentId,
379-
user.privateId,
380-
stageId,
381-
);
382-
await participantAnswerDoc.set({readyToEndChat: true}, {merge: true});
405+
if (stage.kind === StageKind.PRIVATE_CHAT && chatMessages.length > 0) {
406+
if (user.type === UserType.PARTICIPANT) {
407+
// Agent participant is ready to end chat
408+
const participantAnswerDoc = getFirestoreParticipantAnswerRef(
409+
experimentId,
410+
user.privateId,
411+
stageId,
412+
);
413+
await participantAnswerDoc.set({readyToEndChat: true}, {merge: true});
414+
} else if (user.type === UserType.MEDIATOR) {
415+
// Mediator chose not to respond — send a system message so the
416+
// frontend spinner stops and agent participants get unblocked. (#938)
417+
const leftChatLogRef = getPrivateChatTriggerLogRef(
418+
experimentId,
419+
participantIds[0],
420+
stageId,
421+
`left-chat-${user.publicId}`,
422+
);
423+
const alreadySent = (await leftChatLogRef.get()).exists;
424+
if (!alreadySent) {
425+
await leftChatLogRef.set({timestamp: Timestamp.now()});
426+
await sendSystemPrivateChatMessage(
427+
experimentId,
428+
participantIds[0],
429+
stageId,
430+
{
431+
message: `${user.name ?? 'Mediator'} has left the chat.`,
432+
senderId: user.publicId,
433+
profile: createParticipantProfileBase(user),
434+
},
435+
);
436+
}
437+
}
383438
}
384439
return {message: null, success: true};
385440
}

functions/src/chat/chat.utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,35 @@ import {
2222
} from '@deliberation-lab/utils';
2323
import {updateParticipantNextStage} from '../participant.utils';
2424

25+
/** Send a system message to a private chat (e.g., mediator left the chat).
26+
* Unlike error messages, system messages are NOT ignored by chat triggers,
27+
* so they can wake up agent participants.
28+
*/
29+
export async function sendSystemPrivateChatMessage(
30+
experimentId: string,
31+
participantId: string,
32+
stageId: string,
33+
config: Partial<ChatMessage> = {},
34+
) {
35+
const chatMessage = createSystemChatMessage({
36+
...config,
37+
timestamp: Timestamp.now(),
38+
});
39+
40+
const agentDocument = app
41+
.firestore()
42+
.collection('experiments')
43+
.doc(experimentId)
44+
.collection('participants')
45+
.doc(participantId)
46+
.collection('stageData')
47+
.doc(stageId)
48+
.collection('privateChats')
49+
.doc(chatMessage.id);
50+
51+
await agentDocument.set(chatMessage);
52+
}
53+
2554
/** Used for private chats if model response fails. */
2655
export async function sendErrorPrivateChatMessage(
2756
experimentId: string,

functions/src/triggers/chat.triggers.ts

Lines changed: 58 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -144,71 +144,82 @@ export const onPrivateChatMessageCreated = onDocumentCreated(
144144
);
145145
if (!stage) return;
146146

147-
// Send agent mediator messages
148147
const participant = await getFirestoreParticipant(
149148
event.params.experimentId,
150149
event.params.participantId,
151150
);
152151
if (!participant) return;
153152

154-
const mediators = await getFirestoreActiveMediators(
155-
event.params.experimentId,
156-
participant.currentCohortId,
157-
stage.id,
158-
true,
159-
);
160-
161-
await Promise.all(
162-
mediators.map(async (mediator) => {
163-
const result = await createAgentChatMessageFromPrompt(
164-
event.params.experimentId,
165-
participant.currentCohortId,
166-
[participant.privateId],
167-
stage.id,
168-
event.params.chatId,
169-
mediator,
170-
);
153+
// Send agent mediator responses to participant messages only.
154+
// System messages (e.g., "mediator has left") should not trigger
155+
// mediator responses — they are signals, not conversation turns.
156+
if (
157+
message.type !== UserType.MEDIATOR &&
158+
message.type !== UserType.SYSTEM
159+
) {
160+
const mediators = await getFirestoreActiveMediators(
161+
event.params.experimentId,
162+
participant.currentCohortId,
163+
stage.id,
164+
true,
165+
);
171166

172-
if (!result) {
173-
await sendErrorPrivateChatMessage(
167+
await Promise.all(
168+
mediators.map(async (mediator) => {
169+
const result = await createAgentChatMessageFromPrompt(
174170
event.params.experimentId,
175-
participant.privateId,
171+
participant.currentCohortId,
172+
[participant.privateId],
176173
stage.id,
177-
{
178-
discussionId: message.discussionId,
179-
message: 'Error fetching response',
180-
type: mediator.type,
181-
profile: createParticipantProfileBase(mediator),
182-
senderId: mediator.publicId,
183-
agentId: mediator.agentConfig?.agentId ?? '',
184-
},
174+
event.params.chatId,
175+
mediator,
185176
);
186-
}
187-
}),
188-
);
189177

190-
// If no mediator, return error (otherwise participant may wait
191-
// indefinitely for a response).
192-
if (mediators.length === 0) {
193-
await sendErrorPrivateChatMessage(
194-
event.params.experimentId,
195-
participant.privateId,
196-
stage.id,
197-
{
198-
discussionId: message.discussionId,
199-
message: 'No mediators found',
200-
},
178+
if (!result) {
179+
await sendErrorPrivateChatMessage(
180+
event.params.experimentId,
181+
participant.privateId,
182+
stage.id,
183+
{
184+
discussionId: message.discussionId,
185+
message: 'Error fetching response',
186+
type: mediator.type,
187+
profile: createParticipantProfileBase(mediator),
188+
senderId: mediator.publicId,
189+
agentId: mediator.agentConfig?.agentId ?? '',
190+
},
191+
);
192+
}
193+
}),
201194
);
195+
196+
// If no mediator, return error (otherwise participant may wait
197+
// indefinitely for a response).
198+
if (mediators.length === 0) {
199+
await sendErrorPrivateChatMessage(
200+
event.params.experimentId,
201+
participant.privateId,
202+
stage.id,
203+
{
204+
discussionId: message.discussionId,
205+
message: 'No mediators found',
206+
},
207+
);
208+
}
202209
}
203210

204-
// Send agent participant messages (if participant is an agent)
211+
// Send agent participant messages (if participant is an agent).
212+
// Agent responds to mediator messages and system messages (e.g.,
213+
// "mediator has left the chat") so it can advance stages. (#1011)
205214
if (participant.agentConfig) {
206-
// Ensure agent only responds to mediator, not themselves
207-
if (message.type === UserType.MEDIATOR) {
215+
if (
216+
message.type === UserType.MEDIATOR ||
217+
message.type === UserType.SYSTEM
218+
) {
208219
await createAgentChatMessageFromPrompt(
209220
event.params.experimentId,
210221
participant.currentCohortId,
211-
[participant.privateId], // Pass agent's own ID as array
222+
[participant.privateId],
212223
stage.id,
213224
event.params.chatId,
214225
participant,

0 commit comments

Comments
 (0)