diff --git a/docs/assets/api/schemas.json b/docs/assets/api/schemas.json
index 0ea97053a..56431fb30 100644
--- a/docs/assets/api/schemas.json
+++ b/docs/assets/api/schemas.json
@@ -732,6 +732,9 @@
}
]
}
+ },
+ "isTurnBased": {
+ "type": "boolean"
}
}
},
@@ -2828,6 +2831,12 @@
{
"$ref": "#/$defs/StageContextPromptItem"
},
+ {
+ "$ref": "#/$defs/ChatMediatorInstructionsPromptItem"
+ },
+ {
+ "$ref": "#/$defs/ChatParticipantInstructionsPromptItem"
+ },
{
"$ref": "#/$defs/PromptItemGroup"
}
@@ -3020,6 +3029,66 @@
}
}
},
+ "ChatMediatorInstructionsPromptItem": {
+ "title": "ChatMediatorInstructionsPromptItem",
+ "additionalProperties": false,
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": {
+ "const": "CHAT_MEDIATOR_INSTRUCTIONS",
+ "type": "string"
+ },
+ "condition": {
+ "anyOf": [
+ {
+ "type": "null"
+ },
+ {
+ "title": "Condition",
+ "anyOf": [
+ {
+ "$ref": "#/$defs/ComparisonCondition"
+ },
+ {
+ "$ref": "#/$defs/ConditionGroup"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ },
+ "ChatParticipantInstructionsPromptItem": {
+ "title": "ChatParticipantInstructionsPromptItem",
+ "additionalProperties": false,
+ "type": "object",
+ "required": ["type"],
+ "properties": {
+ "type": {
+ "const": "CHAT_PARTICIPANT_INSTRUCTIONS",
+ "type": "string"
+ },
+ "condition": {
+ "anyOf": [
+ {
+ "type": "null"
+ },
+ {
+ "title": "Condition",
+ "anyOf": [
+ {
+ "$ref": "#/$defs/ComparisonCondition"
+ },
+ {
+ "$ref": "#/$defs/ConditionGroup"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ },
"PromptItemGroup": {
"title": "PromptItemGroup",
"additionalProperties": false,
@@ -3049,6 +3118,12 @@
{
"$ref": "#/$defs/StageContextPromptItem"
},
+ {
+ "$ref": "#/$defs/ChatMediatorInstructionsPromptItem"
+ },
+ {
+ "$ref": "#/$defs/ChatParticipantInstructionsPromptItem"
+ },
{
"$ref": "#/$defs/PromptItemGroup"
}
@@ -3959,6 +4034,12 @@
{
"$ref": "#/$defs/StageContextPromptItem"
},
+ {
+ "$ref": "#/$defs/ChatMediatorInstructionsPromptItem"
+ },
+ {
+ "$ref": "#/$defs/ChatParticipantInstructionsPromptItem"
+ },
{
"$ref": "#/$defs/PromptItemGroup"
}
diff --git a/frontend/src/components/chat/chat_info_panel.scss b/frontend/src/components/chat/chat_info_panel.scss
index 102ee5c4e..1e8708751 100644
--- a/frontend/src/components/chat/chat_info_panel.scss
+++ b/frontend/src/components/chat/chat_info_panel.scss
@@ -50,6 +50,21 @@
color: var(--md-sys-color-tertiary);
}
+.profile-row {
+ @include common.flex-row-align-center;
+ gap: common.$spacing-small;
+}
+
+.turn-indicator {
+ flex-shrink: 0;
+ font-size: 14px;
+ visibility: hidden;
+
+ &.visible {
+ visibility: visible;
+ }
+}
+
.profile {
@include common.flex-row-align-center;
gap: common.$spacing-medium;
diff --git a/frontend/src/components/chat/chat_info_panel.ts b/frontend/src/components/chat/chat_info_panel.ts
index a7d3b90a8..5b8a185d4 100644
--- a/frontend/src/components/chat/chat_info_panel.ts
+++ b/frontend/src/components/chat/chat_info_panel.ts
@@ -141,16 +141,28 @@ export class ChatPanel extends MobxLitElement {
`;
}
+ private get currentTurnParticipantId(): string | null {
+ if (!this.stage?.isTurnBased) return null;
+ const data = this.cohortService.stagePublicDataMap[this.stage.id] as
+ | ChatStagePublicData
+ | undefined;
+ return data?.currentTurnParticipantId ?? null;
+ }
+
private renderMediator(profile: MediatorProfile, small = false) {
// TODO: Calculate if mediator is out of messages (maxResponses)
+ const isCurrentTurn = this.currentTurnParticipantId === profile.publicId;
return html`
-
-
-
+
`;
}
@@ -158,13 +170,17 @@ export class ChatPanel extends MobxLitElement {
private renderProfile(profile: ParticipantProfile, small = false) {
const isCurrent =
profile.publicId === this.participantService.profile?.publicId;
+ const isCurrentTurn = this.currentTurnParticipantId === profile.publicId;
return html`
-
-
+
`;
}
}
diff --git a/frontend/src/components/chat/chat_input.scss b/frontend/src/components/chat/chat_input.scss
index 06061c711..a1b3f4367 100644
--- a/frontend/src/components/chat/chat_input.scss
+++ b/frontend/src/components/chat/chat_input.scss
@@ -21,4 +21,21 @@
pr-textarea {
flex-grow: 1;
}
+
+ &.disabled {
+ background: var(--md-sys-color-surface-container-low, #f5f5f5);
+ opacity: 0.7;
+ cursor: not-allowed;
+ border-color: var(--md-sys-color-outline-variant);
+
+ pr-textarea {
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+
+ pr-icon-button {
+ cursor: not-allowed;
+ pointer-events: none;
+ }
+ }
}
diff --git a/frontend/src/components/chat/chat_input.ts b/frontend/src/components/chat/chat_input.ts
index 80ee4347d..a91232452 100644
--- a/frontend/src/components/chat/chat_input.ts
+++ b/frontend/src/components/chat/chat_input.ts
@@ -1,9 +1,7 @@
-import {observable} from 'mobx';
import {MobxLitElement} from '@adobe/lit-mobx';
-import {CSSResultGroup, html, nothing} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {classMap} from 'lit/directives/class-map.js';
+import {CSSResultGroup, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
import {core} from '../../core/core';
import {ParticipantAnswerService} from '../../services/participant.answer';
@@ -40,9 +38,11 @@ export class ChatInputComponent extends MobxLitElement {
};
@property() isDisabled = false;
@property() isLoading = false;
+ @state() hasFocusedOnce = false;
override render() {
const sendInput = async () => {
+ if (this.isLoading || this.isDisabled) return;
this.isLoading = true;
await this.sendUserInput(this.getUserInput());
this.isLoading = false;
@@ -73,18 +73,20 @@ export class ChatInputComponent extends MobxLitElement {
}
};
- const autoFocus = () => {
- // Only auto-focus chat input if on desktop
- return navigator.maxTouchPoints === 0;
+ const shouldFocus = () => {
+ if (this.hasFocusedOnce || this.isDisabled) return false;
+ if (navigator.maxTouchPoints > 0) return false; // mobile
+ this.hasFocusedOnce = true;
+ return true;
};
return html`
-
`;
}
+
+ private renderTurnBanner() {
+ const turnState = this.turnIndicatorState;
+ if (!turnState) return nothing;
+
+ if (turnState.isMyTurn) {
+ return html`
It's your turn to speak!
`;
+ }
+
+ return html`
+
+ Waiting for ${turnState.name} to speak...
+
+ `;
+ }
}
declare global {
diff --git a/frontend/src/components/stages/group_chat_editor.ts b/frontend/src/components/stages/group_chat_editor.ts
index 08c8b3752..3f0ad0d15 100644
--- a/frontend/src/components/stages/group_chat_editor.ts
+++ b/frontend/src/components/stages/group_chat_editor.ts
@@ -30,7 +30,7 @@ export class ChatEditor extends MobxLitElement {
return html`
Conversation settings
- ${this.renderTimeLimit()}
+ ${this.renderTimeLimit()} ${this.renderTurnBasedSetting()}
Agent mediators
@@ -40,6 +40,32 @@ export class ChatEditor extends MobxLitElement {
`;
}
+ private renderTurnBasedSetting() {
+ return html`
+
+
+
{
+ const checked = (e.target as HTMLInputElement).checked;
+ this.experimentEditor.updateStage({
+ ...this.stage!,
+ isTurnBased: checked,
+ });
+ }}
+ >
+
+
+ Turn-based conversation: Each participant speaks in a random order,
+ beginning with the mediators if at least one is present.
+
+
+
+ `;
+ }
+
private renderMediators() {
const agentMediators = this.experimentEditor.agentMediators.filter(
(template) => template.promptMap[this.stage?.id ?? ''],
diff --git a/functions/src/agent.utils.ts b/functions/src/agent.utils.ts
index bec64bda5..8d627b0e1 100644
--- a/functions/src/agent.utils.ts
+++ b/functions/src/agent.utils.ts
@@ -13,6 +13,27 @@ import {
import {generateAIResponse, ModelMessage} from './api/ai-sdk.api';
import {formatPromptForLog, writeModelLogEntry} from './log.utils';
+class RetryTimeoutError extends Error {
+ constructor() {
+ super('Retry deadline reached before model response succeeded');
+ this.name = 'RetryTimeoutError';
+ }
+}
+
+async function withRetryTimeout
(
+ promise: Promise,
+ timeoutMs: number,
+): Promise {
+ let timeout: ReturnType | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new RetryTimeoutError()), timeoutMs);
+ });
+
+ return Promise.race([promise, timeoutPromise]).finally(() => {
+ if (timeout) clearTimeout(timeout);
+ });
+}
+
/** Calls API and writes ModelLogEntry to experiment. */
export async function processModelResponse(
experimentId: string,
@@ -28,17 +49,28 @@ export async function processModelResponse(
modelSettings: AgentModelSettings,
generationConfig: ModelGenerationConfig,
structuredOutputConfig?: StructuredOutputConfig,
- numRetries: number = 0,
-): Promise<{response: ModelResponse; logId: string}> {
+ numRetries: number | null = 0,
+ maxRetryDurationMs: number | null = null,
+): Promise<{
+ response: ModelResponse;
+ logId: string;
+ retryTimedOut: boolean;
+}> {
let response = {status: ModelResponseStatus.NONE};
let lastError: Error | undefined;
+ let retryTimedOut = false;
const maxRetries = numRetries;
const initialDelay = 1000; // 1 second initial delay
let logId = '';
+ const retryStartMs = Date.now();
// Convert prompt to string for logging (reused across retries)
const promptForLog = formatPromptForLog(prompt);
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ for (
+ let attempt = 0;
+ maxRetries === null || attempt <= maxRetries;
+ attempt++
+ ) {
// Create a new log entry for each attempt
const log = createModelLogEntry({
experimentId,
@@ -57,14 +89,25 @@ export async function processModelResponse(
logId = log.id;
try {
+ const remainingRetryMs =
+ maxRetryDurationMs === null
+ ? null
+ : maxRetryDurationMs - (Date.now() - retryStartMs);
+ if (remainingRetryMs !== null && remainingRetryMs <= 0) {
+ throw new RetryTimeoutError();
+ }
+
const queryTimestamp = Timestamp.now();
- response = (await getAgentResponse(
+ const request = getAgentResponse(
apiKeyConfig,
prompt,
modelSettings,
generationConfig,
structuredOutputConfig,
- )) as ModelResponse;
+ );
+ response = (await (remainingRetryMs === null
+ ? request
+ : withRetryTimeout(request, remainingRetryMs))) as ModelResponse;
const responseTimestamp = Timestamp.now();
log.response = response;
@@ -73,10 +116,13 @@ export async function processModelResponse(
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.log(error);
+ retryTimedOut = error instanceof RetryTimeoutError;
// Log the error response
log.response = {
- status: ModelResponseStatus.UNKNOWN_ERROR,
+ status: retryTimedOut
+ ? ModelResponseStatus.PROVIDER_UNAVAILABLE_ERROR
+ : ModelResponseStatus.UNKNOWN_ERROR,
errorMessage: lastError.message,
};
log.queryTimestamp = Timestamp.now();
@@ -88,15 +134,29 @@ export async function processModelResponse(
// Check if we should retry
const shouldRetry =
- attempt < maxRetries &&
+ !retryTimedOut &&
+ (maxRetries === null || attempt < maxRetries) &&
(response.status === ModelResponseStatus.PROVIDER_UNAVAILABLE_ERROR ||
response.status === ModelResponseStatus.INTERNAL_ERROR ||
response.status === ModelResponseStatus.UNKNOWN_ERROR);
if (shouldRetry) {
- const delay = initialDelay * Math.pow(2, attempt);
+ const baseDelay = initialDelay * Math.pow(2, attempt);
+ // Clamp to remaining budget so the next attempt fires before the deadline.
+ const delay =
+ maxRetryDurationMs === null
+ ? baseDelay
+ : Math.max(
+ 0,
+ Math.min(
+ baseDelay,
+ maxRetryDurationMs - (Date.now() - retryStartMs),
+ ),
+ );
+ const retryCount =
+ maxRetries === null ? `${attempt + 1}` : `${attempt + 1}/${maxRetries}`;
console.log(
- `API error (${response.status}), retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})`,
+ `API error (${response.status}), retrying after ${delay}ms (attempt ${retryCount})`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
@@ -107,10 +167,12 @@ export async function processModelResponse(
// If we exhausted all retries with an error, log it
if (lastError && response.status === ModelResponseStatus.NONE) {
- console.error(`Failed after ${numRetries} retries:`, lastError);
+ const retryDescription =
+ maxRetries === null ? 'unlimited retries' : `${numRetries} retries`;
+ console.error(`Failed after ${retryDescription}:`, lastError);
}
- return {response, logId};
+ return {response, logId, retryTimedOut};
}
/**
diff --git a/functions/src/chat/chat.agent.ts b/functions/src/chat/chat.agent.ts
index db10591f5..4af306d99 100644
--- a/functions/src/chat/chat.agent.ts
+++ b/functions/src/chat/chat.agent.ts
@@ -3,6 +3,7 @@ import {
ChatMediatorStructuredOutputConfig,
ChatMessage,
ChatPromptConfig,
+ ChatStageConfig,
ChatStagePublicData,
extractChatMediatorStructuredFields,
getStructuredOutput,
@@ -15,7 +16,9 @@ import {
awaitTypingDelay,
createChatMessage,
createParticipantProfileBase,
+ createSystemChatMessage,
sanitizeRawResponseForLogging,
+ shuffleWithSeed,
} from '@deliberation-lab/utils';
import {Timestamp} from 'firebase-admin/firestore';
import {processModelResponse} from '../agent.utils';
@@ -54,6 +57,9 @@ import {updateModelLogFiles} from '../log.utils';
// Functions for preparing, querying, and organizing agent chat responses.
// ****************************************************************************
+// 300s cloud function timeout minus 10s buffer for skip handler.
+const TURN_BASED_AGENT_RETRY_TIMEOUT_MS = 290000;
+
/** Use persona chat prompt to create and send agent chat message. */
export async function createAgentChatMessageFromPrompt(
experimentId: string,
@@ -63,12 +69,19 @@ export async function createAgentChatMessageFromPrompt(
triggerChatId: string, // ID of chat that is being responded to (empty string for initial message)
// Profile of agent who will be sending the chat message
user: ParticipantProfileExtended | MediatorProfileExtended,
+ turnBasedRetryDeadlineMs?: number,
) {
- if (!user.agentConfig) return false;
+ if (!user.agentConfig) {
+ console.log(`[chat.agent] User ${user.publicId} has no agentConfig`);
+ return false;
+ }
// Stage (in order to determine stage kind)
const stage = await getFirestoreStage(experimentId, stageId);
- if (!stage) return false;
+ if (!stage) {
+ console.log(`[chat.agent] Stage ${stageId} not found`);
+ return false;
+ }
// Fetches stored (else default) prompt config for given stage
const promptConfig = (await getStructuredPromptConfig(
@@ -78,10 +91,20 @@ export async function createAgentChatMessageFromPrompt(
)) as ChatPromptConfig | undefined;
if (!promptConfig) {
+ console.log(
+ `[chat.agent] PromptConfig not found for user ${user.publicId} in stage ${stageId}`,
+ );
return false;
}
const isPrivateChat = stage.kind === StageKind.PRIVATE_CHAT;
+ const isTurnBasedGroupChat =
+ stage.kind === StageKind.CHAT && (stage as ChatStageConfig).isTurnBased;
+ const retryDeadlineMs =
+ turnBasedRetryDeadlineMs ??
+ (isTurnBasedGroupChat
+ ? Date.now() + TURN_BASED_AGENT_RETRY_TIMEOUT_MS
+ : undefined);
// Check if this is an initial message request (empty triggerChatId)
if (triggerChatId === '') {
@@ -103,13 +126,19 @@ export async function createAgentChatMessageFromPrompt(
triggerLogId,
);
- const hasAlreadySent = (await triggerLogRef.get()).exists;
- if (hasAlreadySent) {
+ const shouldSendInitialMessage = await app
+ .firestore()
+ .runTransaction(async (transaction) => {
+ const triggerLog = await transaction.get(triggerLogRef);
+ if (triggerLog.exists) return false;
+
+ transaction.create(triggerLogRef, {timestamp: Timestamp.now()});
+ return true;
+ });
+
+ if (!shouldSendInitialMessage) {
return false; // Already sent initial message
}
-
- // Mark that we're sending the initial message
- await triggerLogRef.set({timestamp: Timestamp.now()});
}
// Get the chat message (either initial or response)
@@ -147,9 +176,64 @@ export async function createAgentChatMessageFromPrompt(
stage,
user,
promptConfig,
+ retryDeadlineMs,
);
message = response.message;
if (!message) {
+ if (isTurnBasedGroupChat && response.retryTimedOut) {
+ // Clean up the initial trigger log lock if it timed out and we are skipping the agent
+ if (triggerChatId === '') {
+ const triggerLogId = `initial-${user.publicId}`;
+ const triggerLogRef = getGroupChatTriggerLogRef(
+ experimentId,
+ cohortId,
+ stageId,
+ triggerLogId,
+ );
+ await triggerLogRef.delete();
+ }
+
+ await skipTimedOutTurnBasedAgentTurn(
+ experimentId,
+ cohortId,
+ stage,
+ triggerChatId,
+ user,
+ );
+ return true;
+ }
+
+ if (triggerChatId === '') {
+ const triggerLogId = `initial-${user.publicId}`;
+ console.log(
+ `[chat.agent] getAgentChatMessage failed for initial message. Deleting trigger log: ${triggerLogId}`,
+ );
+ const isTurnBased =
+ stage?.kind === StageKind.CHAT &&
+ (stage as ChatStageConfig).isTurnBased;
+
+ let triggerLogRef;
+ if (isTurnBased) {
+ triggerLogRef = getGroupChatTriggerLogRef(
+ experimentId,
+ cohortId,
+ stageId,
+ triggerLogId,
+ );
+ } else {
+ triggerLogRef = app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('cohorts')
+ .doc(cohortId)
+ .collection('publicStageData')
+ .doc(stageId)
+ .collection('agentInitialTriggerLog')
+ .doc(triggerLogId);
+ }
+ await triggerLogRef.delete();
+ }
return response.success;
}
}
@@ -194,13 +278,20 @@ export async function getAgentChatMessage(
// Agent who will be sending the message
user: ParticipantProfileExtended | MediatorProfileExtended,
promptConfig: ChatPromptConfig,
-): Promise<{message: ChatMessage | null; success: boolean}> {
+ turnBasedRetryDeadlineMs?: number,
+): Promise<{
+ message: ChatMessage | null;
+ success: boolean;
+ retryTimedOut: boolean;
+}> {
const stageId = stage.id;
// Fetch experiment creator's API key.
const experimenterData =
await getExperimenterDataFromExperiment(experimentId);
- if (!experimenterData) return {message: null, success: false};
+ if (!experimenterData) {
+ return {message: null, success: false, retryTimedOut: false};
+ }
// Get chat messages from private/public data based on stage kind
const chatMessages =
@@ -216,15 +307,33 @@ export async function getAgentChatMessage(
stageId,
);
+ // For turn-based conversation, ensure it is the agent's turn
+ const isTurnBasedGroupChat =
+ stage.kind === StageKind.CHAT && (stage as ChatStageConfig).isTurnBased;
+ if (isTurnBasedGroupChat) {
+ const publicStageData = await getFirestoreStagePublicData(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+ const chatPublicData = publicStageData as ChatStagePublicData;
+ if (
+ chatPublicData &&
+ chatPublicData.currentTurnParticipantId !== user.publicId
+ ) {
+ return {message: null, success: true, retryTimedOut: false};
+ }
+ }
+
// Confirm that agent can send chat messages based on prompt config
const chatSettings = promptConfig.chatSettings;
if (!canSendAgentChatMessage(user.publicId, chatSettings, chatMessages)) {
- return {message: null, success: true};
+ return {message: null, success: true, retryTimedOut: false};
}
// Ensure user has agent config
if (!user.agentConfig) {
- return {message: null, success: false};
+ return {message: null, success: false, retryTimedOut: false};
}
// Use provided participant IDs for prompt context
@@ -274,7 +383,34 @@ export async function getAgentChatMessage(
prompt = structuredPrompt;
}
- const {response, logId} = await processModelResponse(
+ const retryDurationMs =
+ isTurnBasedGroupChat && turnBasedRetryDeadlineMs !== undefined
+ ? Math.max(0, turnBasedRetryDeadlineMs - Date.now())
+ : null;
+
+ // In turn-based mode the agent always responds when called — strip shouldRespond
+ // from the API-level schema constraint so the model isn't forced to output a
+ // field whose "stay silent" path would freeze the turn. The prompt text is
+ // already filtered in structured_prompt.utils.ts; this keeps the two in sync.
+ const effectiveStructuredOutputConfig = (() => {
+ const config = promptConfig.structuredOutputConfig as
+ | ChatMediatorStructuredOutputConfig
+ | undefined;
+ if (!isTurnBasedGroupChat || !config?.schema?.properties) return config;
+ const shouldRespondFieldName = config.shouldRespondField || 'shouldRespond';
+ return {
+ ...config,
+ schema: {
+ ...config.schema,
+ properties: config.schema.properties.filter(
+ (p) => p.name !== shouldRespondFieldName,
+ ),
+ },
+ shouldRespondField: '',
+ } as ChatMediatorStructuredOutputConfig;
+ })();
+
+ const {response, logId, retryTimedOut} = await processModelResponse(
experimentId,
cohortId,
participantIds[0] || '', // Use first participant ID for logging/tracking
@@ -287,8 +423,9 @@ export async function getAgentChatMessage(
prompt,
user.agentConfig.modelSettings,
promptConfig.generationConfig,
- promptConfig.structuredOutputConfig,
- promptConfig.numRetries ?? 0, // Pass numRetries from config
+ effectiveStructuredOutputConfig,
+ isTurnBasedGroupChat ? null : (promptConfig.numRetries ?? 0),
+ retryDurationMs,
);
// Log response with sanitized rawResponse and files to avoid overwhelming console with file/image data
@@ -311,10 +448,10 @@ export async function getAgentChatMessage(
// Process response
if (response.status !== ModelResponseStatus.OK) {
- return {message: null, success: false};
+ return {message: null, success: false, retryTimedOut};
}
- const structured = promptConfig.structuredOutputConfig as
+ const structured = effectiveStructuredOutputConfig as
| ChatMediatorStructuredOutputConfig
| undefined;
@@ -342,7 +479,7 @@ export async function getAgentChatMessage(
// No text and no files = failure
if (!response.text && (!response.files || response.files.length === 0)) {
- return {message: null, success: false};
+ return {message: null, success: false, retryTimedOut};
}
if (!shouldRespond) {
@@ -381,7 +518,7 @@ export async function getAgentChatMessage(
);
await participantAnswerDoc.set({readyToEndChat: true}, {merge: true});
}
- return {message: null, success: true};
+ return {message: null, success: true, retryTimedOut};
}
// If stage includes discussions, figure out what discussion ID should be
@@ -432,7 +569,226 @@ export async function getAgentChatMessage(
}
}
- return {message: chatMessage, success: true};
+ return {message: chatMessage, success: true, retryTimedOut};
+}
+
+async function getNextTurnBasedSpeakerAfterSkippedAgent(
+ experimentId: string,
+ cohortId: string,
+ stage: ChatStageConfig,
+ publicStageData: ChatStagePublicData,
+ skippedPublicId: string,
+ activeParticipants: ParticipantProfileExtended[],
+ activeMediators: MediatorProfileExtended[],
+ chatMessages: ChatMessage[],
+): Promise<{
+ currentTurnParticipantId: string;
+ turnOrder: string[];
+ cycleIndex: number;
+ agent: ParticipantProfileExtended | MediatorProfileExtended | null;
+} | null> {
+ const participantIds = activeParticipants.map((p) => p.publicId);
+ const mediatorIds = activeMediators.map((m) => m.publicId);
+ const activeIds = new Set([...mediatorIds, ...participantIds]);
+
+ let turnOrder = (publicStageData.turnOrder ?? []).filter((id) =>
+ activeIds.has(id),
+ );
+ let cycleIndex = publicStageData.cycleIndex ?? 0;
+ let nextIndex = turnOrder.indexOf(skippedPublicId);
+ let attempts = 0;
+
+ while (attempts < turnOrder.length) {
+ if (nextIndex === -1 || nextIndex === turnOrder.length - 1) {
+ cycleIndex += 1;
+
+ let nextTurnOrder = [...turnOrder];
+ if (mediatorIds.length > 0) {
+ const currentMediators = turnOrder.filter((id) =>
+ mediatorIds.includes(id),
+ );
+ const missingMediators = mediatorIds.filter(
+ (id) => !turnOrder.includes(id),
+ );
+ const shuffledMissingMediators =
+ missingMediators.length > 1
+ ? shuffleWithSeed(
+ missingMediators,
+ `${cohortId}-new-mediators-${cycleIndex}`,
+ )
+ : missingMediators;
+ const nextMediators = [
+ ...currentMediators,
+ ...shuffledMissingMediators,
+ ];
+
+ const hadMediators = turnOrder.some((id) => mediatorIds.includes(id));
+ if (hadMediators) {
+ const shuffledParticipants = shuffleWithSeed(
+ participantIds,
+ `${cohortId}-${cycleIndex}`,
+ );
+ nextTurnOrder = [...nextMediators, ...shuffledParticipants];
+ } else {
+ const currentParticipants = turnOrder.filter((id) =>
+ participantIds.includes(id),
+ );
+ nextTurnOrder = [...nextMediators, ...currentParticipants];
+ }
+ }
+
+ turnOrder = nextTurnOrder;
+ nextIndex = 0;
+ } else {
+ nextIndex += 1;
+ }
+
+ const nextPublicId = turnOrder[nextIndex];
+ const candidate =
+ activeParticipants.find((p) => p.publicId === nextPublicId) ??
+ activeMediators.find((m) => m.publicId === nextPublicId);
+
+ if (
+ !candidate ||
+ candidate.publicId === skippedPublicId ||
+ !activeIds.has(candidate.publicId)
+ ) {
+ attempts++;
+ continue;
+ }
+
+ if (candidate.agentConfig) {
+ const promptConfig = (await getStructuredPromptConfig(
+ experimentId,
+ stage,
+ candidate,
+ )) as ChatPromptConfig | undefined;
+
+ if (
+ promptConfig &&
+ !canSendAgentChatMessage(
+ candidate.publicId,
+ promptConfig.chatSettings,
+ chatMessages,
+ )
+ ) {
+ attempts++;
+ continue;
+ }
+ }
+
+ return {
+ currentTurnParticipantId: candidate.publicId,
+ turnOrder,
+ cycleIndex,
+ agent: candidate.agentConfig ? candidate : null,
+ };
+ }
+
+ return null;
+}
+
+export async function skipTimedOutTurnBasedAgentTurn(
+ experimentId: string,
+ cohortId: string,
+ stage: ChatStageConfig,
+ triggerChatId: string,
+ user: ParticipantProfileExtended | MediatorProfileExtended,
+ skipReason = 'a model error',
+) {
+ const [publicStageData, activeParticipants, activeMediators, chatMessages] =
+ await Promise.all([
+ getFirestoreStagePublicData(experimentId, cohortId, stage.id) as Promise<
+ ChatStagePublicData | undefined
+ >,
+ getFirestoreActiveParticipants(experimentId, cohortId, stage.id, false),
+ getFirestoreActiveMediators(experimentId, cohortId, stage.id, true),
+ getFirestorePublicStageChatMessages(experimentId, cohortId, stage.id),
+ ]);
+
+ if (publicStageData?.currentTurnParticipantId !== user.publicId) return;
+
+ const nextSpeaker = await getNextTurnBasedSpeakerAfterSkippedAgent(
+ experimentId,
+ cohortId,
+ stage,
+ publicStageData,
+ user.publicId,
+ activeParticipants,
+ activeMediators,
+ chatMessages,
+ );
+ if (!nextSpeaker) return;
+
+ const publicStageDataRef = app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('cohorts')
+ .doc(cohortId)
+ .collection('publicStageData')
+ .doc(stage.id);
+
+ const didAdvance = await app
+ .firestore()
+ .runTransaction(async (transaction) => {
+ const snapshot = await transaction.get(publicStageDataRef);
+ const currentPublicStageData = snapshot.data() as
+ | ChatStagePublicData
+ | undefined;
+ if (currentPublicStageData?.currentTurnParticipantId !== user.publicId) {
+ return false;
+ }
+
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId: nextSpeaker.currentTurnParticipantId,
+ turnOrder: nextSpeaker.turnOrder,
+ cycleIndex: nextSpeaker.cycleIndex,
+ },
+ {merge: true},
+ );
+ return true;
+ });
+
+ if (!didAdvance) return;
+
+ console.warn(
+ `[chat.agent] Skipped timed-out turn for ${user.publicId}; next turn is ${nextSpeaker.currentTurnParticipantId}`,
+ );
+
+ const skipMessage = createSystemChatMessage({
+ message: `${user.name ?? user.publicId} was skipped due to ${skipReason}.`,
+ });
+ await app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('cohorts')
+ .doc(cohortId)
+ .collection('publicStageData')
+ .doc(stage.id)
+ .collection('chats')
+ .doc(skipMessage.id)
+ .set(skipMessage);
+
+ if (!nextSpeaker.agent) return;
+
+ // The skip system message is now the latest message; use its id as the
+ // trigger so the downstream "conversation has moved on" check in
+ // sendAgentGroupChatMessage doesn't bail on the stale pre-skip id.
+ const nextTriggerChatId = skipMessage.id;
+ await createAgentChatMessageFromPrompt(
+ experimentId,
+ cohortId,
+ nextSpeaker.agent.type === UserType.MEDIATOR
+ ? activeParticipants.map((p) => p.privateId)
+ : [nextSpeaker.agent.privateId],
+ stage.id,
+ nextTriggerChatId,
+ nextSpeaker.agent,
+ );
}
/** Sends agent chat message after typing delay and duplicate check. */
@@ -459,9 +815,12 @@ export async function sendAgentGroupChatMessage(
cohortId,
stageId,
);
+ const nonSystemHistory = chatHistory.filter(
+ (m) => m.type !== UserType.SYSTEM,
+ );
if (
- chatHistory.length > 0 &&
- chatHistory[chatHistory.length - 1].id !== triggerChatId
+ nonSystemHistory.length > 0 &&
+ nonSystemHistory[nonSystemHistory.length - 1].id !== triggerChatId
) {
// TODO: Write chat log
console.log('Conversation has moved on');
@@ -531,9 +890,12 @@ export async function sendAgentPrivateChatMessage(
participantId,
stageId,
);
+ const nonSystemHistory = chatHistory.filter(
+ (m) => m.type !== UserType.SYSTEM,
+ );
if (
- chatHistory.length > 0 &&
- chatHistory[chatHistory.length - 1].id !== triggerChatId
+ nonSystemHistory.length > 0 &&
+ nonSystemHistory[nonSystemHistory.length - 1].id !== triggerChatId
) {
// TODO: Write chat log
console.log('Conversation has moved on');
@@ -676,6 +1038,177 @@ async function sendInitialGroupChatMessages(
true, // checkIsAgent = true
);
+ const stage = await getFirestoreStage(experimentId, stageId);
+ const chatStage = stage as ChatStageConfig;
+
+ if (chatStage.isTurnBased) {
+ const publicStageData = await getFirestoreStagePublicData(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+ const chatPublicData = publicStageData as ChatStagePublicData;
+
+ // If already initialized, append any new participants to the end of the
+ // current turn order, and any new mediators to the end of the mediator
+ // phase (after existing mediators, before participants). Never touch
+ // currentTurnParticipantId — the in-flight onPublicChatMessageCreated
+ // trigger already owns turn advancement.
+ if (chatPublicData && chatPublicData.currentTurnParticipantId) {
+ const publicStageDataRef = app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('cohorts')
+ .doc(cohortId)
+ .collection('publicStageData')
+ .doc(stageId);
+
+ await app.firestore().runTransaction(async (transaction) => {
+ const snapshot = await transaction.get(publicStageDataRef);
+ const currentData = snapshot.data() as ChatStagePublicData | undefined;
+ if (!currentData || !currentData.currentTurnParticipantId) return;
+
+ const turnOrder = currentData.turnOrder ?? [];
+ const existingIds = new Set(turnOrder);
+ const allMediatorIds = new Set(agentMediators.map((m) => m.publicId));
+
+ const newMediatorIds = agentMediators
+ .map((m) => m.publicId)
+ .filter((id) => !existingIds.has(id));
+ const newParticipantIds = allParticipants
+ .map((p) => p.publicId)
+ .filter((id) => !existingIds.has(id));
+
+ if (newMediatorIds.length === 0 && newParticipantIds.length === 0) {
+ return;
+ }
+
+ // New mediators go after the last existing mediator (end of the
+ // mediator phase); new participants go at the very end.
+ const lastMediatorIdx = turnOrder.reduce(
+ (last, id, i) => (allMediatorIds.has(id) ? i : last),
+ -1,
+ );
+
+ const updatedTurnOrder = [
+ ...turnOrder.slice(0, lastMediatorIdx + 1),
+ ...newMediatorIds,
+ ...turnOrder.slice(lastMediatorIdx + 1),
+ ...newParticipantIds,
+ ];
+
+ transaction.set(
+ publicStageDataRef,
+ {turnOrder: updatedTurnOrder},
+ {merge: true},
+ );
+ });
+
+ return;
+ }
+
+ // If uninitialized
+ if (!chatPublicData || !chatPublicData.currentTurnParticipantId) {
+ const allPublicParticipantIds = allParticipants.map((p) => p.publicId);
+ const allMediatorIds = agentMediators.map((m) => m.publicId);
+
+ // Shuffle participants with seed
+ const seedString = `${cohortId}-0`;
+ const shuffledParticipants = shuffleWithSeed(
+ allPublicParticipantIds,
+ seedString,
+ );
+
+ // Shuffle mediator order when conversation begins (only if multiple)
+ const shuffledMediators =
+ allMediatorIds.length > 1
+ ? shuffleWithSeed(allMediatorIds, `${cohortId}-mediators`)
+ : allMediatorIds;
+
+ const turnOrder = [...shuffledMediators, ...shuffledParticipants];
+
+ // Find the first eligible initial turn holder, skipping agents that
+ // can't send yet (e.g., minMessagesBeforeResponding > 0).
+ let currentTurnParticipantId: string | null = null;
+ for (const id of turnOrder) {
+ const mediatorCandidate = agentMediators.find((m) => m.publicId === id);
+ const participantCandidate = allParticipants.find(
+ (p) => p.publicId === id,
+ );
+ const candidate = mediatorCandidate ?? participantCandidate;
+ if (!candidate) continue;
+
+ if (candidate.agentConfig) {
+ const candidatePromptConfig = (await getStructuredPromptConfig(
+ experimentId,
+ chatStage,
+ candidate,
+ )) as ChatPromptConfig | undefined;
+ if (
+ candidatePromptConfig &&
+ !canSendAgentChatMessage(
+ id,
+ candidatePromptConfig.chatSettings,
+ [], // no messages yet
+ )
+ ) {
+ continue; // not eligible yet, try next
+ }
+ }
+
+ currentTurnParticipantId = id;
+ break;
+ }
+ if (!currentTurnParticipantId && turnOrder.length > 0) {
+ currentTurnParticipantId = turnOrder[0];
+ }
+
+ // Update Firestore publicStageData
+ const publicStageDataRef = app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('cohorts')
+ .doc(cohortId)
+ .collection('publicStageData')
+ .doc(stageId);
+
+ await publicStageDataRef.set(
+ {
+ currentTurnParticipantId,
+ turnOrder,
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+
+ // Trigger an initial message if the first turn holder is an agent.
+ if (currentTurnParticipantId) {
+ const mediator = agentMediators.find(
+ (m) => m.publicId === currentTurnParticipantId,
+ );
+ const participant = allParticipants.find(
+ (p) => p.publicId === currentTurnParticipantId,
+ );
+ const agent =
+ mediator ?? (participant?.agentConfig ? participant : null);
+
+ if (agent) {
+ await createAgentChatMessageFromPrompt(
+ experimentId,
+ cohortId,
+ mediator ? allParticipantIds : [agent.privateId],
+ stageId,
+ '', // empty triggerChatId
+ agent,
+ );
+ }
+ }
+ return; // Return so we don't run the default broadcast below!
+ }
+ }
+
for (const mediator of agentMediators) {
await createAgentChatMessageFromPrompt(
experimentId,
diff --git a/functions/src/participant.endpoints.ts b/functions/src/participant.endpoints.ts
index 3a039fd51..b5d2c05d6 100644
--- a/functions/src/participant.endpoints.ts
+++ b/functions/src/participant.endpoints.ts
@@ -15,12 +15,15 @@ import {
VariableScope,
getTimeElapsed,
ChatStagePublicData,
+ ChatStageConfig,
} from '@deliberation-lab/utils';
import {
+ getFirestoreCohort,
getFirestoreStage,
getFirestoreStagePublicData,
getFirestorePrivateChatMessages,
} from './utils/firestore';
+import {sendInitialChatMessages} from './chat/chat.agent';
import {
updateCohortStageUnlocked,
updateParticipantNextStage,
@@ -488,6 +491,9 @@ export const acceptParticipantExperimentStart = onCall(
.collection('participants')
.doc(privateId);
+ let currentCohortId = '';
+ let currentStageId = '';
+
// Run document write as transaction to ensure consistency
await app.firestore().runTransaction(async (transaction) => {
const participant = (
@@ -495,8 +501,10 @@ export const acceptParticipantExperimentStart = onCall(
).data() as ParticipantProfileExtended;
participant.timestamps.startExperiment = Timestamp.now();
+ currentCohortId = participant.currentCohortId;
+ currentStageId = participant.currentStageId;
+
// Set current stage as ready to start
- const currentStageId = participant.currentStageId;
participant.timestamps.readyStages[currentStageId] = Timestamp.now();
// If all active participants have reached the next stage,
@@ -511,6 +519,24 @@ export const acceptParticipantExperimentStart = onCall(
transaction.set(document, participant);
});
+ // If the initial stage is a turn-based chat stage, trigger initial messages immediately
+ // since there is no prior stage progression to fire onParticipantStageDataUpdated.
+ const stage = await getFirestoreStage(data.experimentId, currentStageId);
+ const isTurnBased =
+ stage?.kind === StageKind.CHAT && (stage as ChatStageConfig).isTurnBased;
+ const cohort = await getFirestoreCohort(data.experimentId, currentCohortId);
+ if (isTurnBased && cohort?.stageUnlockMap?.[currentStageId]) {
+ console.log(
+ `[participant.endpoints] Initial stage ${currentStageId} is turn-based. Triggering sendInitialChatMessages!`,
+ );
+ await sendInitialChatMessages(
+ data.experimentId,
+ currentCohortId,
+ currentStageId,
+ privateId,
+ );
+ }
+
return {success: true};
},
);
diff --git a/functions/src/structured_prompt.utils.ts b/functions/src/structured_prompt.utils.ts
index 16a333cda..73ff1bfb5 100644
--- a/functions/src/structured_prompt.utils.ts
+++ b/functions/src/structured_prompt.utils.ts
@@ -29,6 +29,11 @@ import {
resolveTemplateVariables,
shuffleWithSeed,
StageParticipantAnswer,
+ DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT,
+ DEFAULT_AGENT_PARTICIPANT_CHAT_TURN_TAKING_PROMPT,
+ DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS,
+ DEFAULT_MEDIATOR_GROUP_CHAT_TURN_TAKING_PROMPT_INSTRUCTIONS,
+ ChatStageConfig,
} from '@deliberation-lab/utils';
import {
getAgentMediatorPrompt,
@@ -340,9 +345,25 @@ export async function getPromptFromConfig(
);
// Add structured output if relevant
- const structuredOutput = makeStructuredOutputPrompt(
- promptConfig.structuredOutputConfig,
- );
+ const stage = promptData.data[stageId]?.stage;
+ const isTurnBased =
+ stage?.kind === StageKind.CHAT && (stage as ChatStageConfig).isTurnBased;
+ let structuredOutputConfig = promptConfig.structuredOutputConfig;
+
+ if (isTurnBased && structuredOutputConfig?.schema?.properties) {
+ const sanitizedProperties = structuredOutputConfig.schema.properties.filter(
+ (prop) => prop.name !== 'shouldRespond',
+ );
+ structuredOutputConfig = {
+ ...structuredOutputConfig,
+ schema: {
+ ...structuredOutputConfig.schema,
+ properties: sanitizedProperties,
+ },
+ };
+ }
+
+ const structuredOutput = makeStructuredOutputPrompt(structuredOutputConfig);
return structuredOutput ? `${promptText}\n${structuredOutput}` : promptText;
}
@@ -582,7 +603,7 @@ async function processPromptItems(
}
switch (promptItem.type) {
- case PromptItemType.TEXT:
+ case PromptItemType.TEXT: {
// Resolve template variables in text prompt items
const resolvedText = resolveTemplateVariables(
promptItem.text,
@@ -591,6 +612,31 @@ async function processPromptItems(
);
items.push(resolvedText);
break;
+ }
+ case PromptItemType.CHAT_MEDIATOR_INSTRUCTIONS: {
+ const stage = promptData.data[stageId]?.stage;
+ const isTurnBased =
+ stage?.kind === StageKind.CHAT &&
+ (stage as ChatStageConfig).isTurnBased;
+ items.push(
+ isTurnBased
+ ? DEFAULT_MEDIATOR_GROUP_CHAT_TURN_TAKING_PROMPT_INSTRUCTIONS
+ : DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS,
+ );
+ break;
+ }
+ case PromptItemType.CHAT_PARTICIPANT_INSTRUCTIONS: {
+ const stage = promptData.data[stageId]?.stage;
+ const isTurnBased =
+ stage?.kind === StageKind.CHAT &&
+ (stage as ChatStageConfig).isTurnBased;
+ items.push(
+ isTurnBased
+ ? DEFAULT_AGENT_PARTICIPANT_CHAT_TURN_TAKING_PROMPT
+ : DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT,
+ );
+ break;
+ }
case PromptItemType.PROFILE_CONTEXT:
const profileContext = getProfileContextForPrompt(
userProfile,
diff --git a/functions/src/triggers/chat.triggers.test.ts b/functions/src/triggers/chat.triggers.test.ts
new file mode 100644
index 000000000..442054f31
--- /dev/null
+++ b/functions/src/triggers/chat.triggers.test.ts
@@ -0,0 +1,784 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable @typescript-eslint/no-require-imports */
+import {
+ StageKind,
+ UserType,
+ ChatMessage,
+ shuffleWithSeed,
+} from '@deliberation-lab/utils';
+
+// -----------------------------------------------------------------------------
+// MOCK SETUP (hoisted inside jest.mock blocks to avoid temporal dead zone)
+// -----------------------------------------------------------------------------
+
+// Mock '@deliberation-lab/utils' to intercept shuffleWithSeed
+jest.mock('@deliberation-lab/utils', () => {
+ const original = jest.requireActual('@deliberation-lab/utils');
+ const mockShuffle = jest
+ .fn()
+ .mockImplementation((arr: any[], seed?: string) => {
+ return original.shuffleWithSeed(arr, seed);
+ });
+ return {
+ __esModule: true,
+ ...original,
+ shuffleWithSeed: mockShuffle,
+ };
+});
+
+// Mock './app' before importing function under test
+jest.mock('../app', () => {
+ const mockSet = jest.fn().mockResolvedValue(undefined);
+ const mockDelete = jest.fn().mockResolvedValue(undefined);
+ const mockGet = jest.fn().mockImplementation(async (path: string) => {
+ if (path.includes('/publicStageData/')) {
+ const mockGetFirestoreStagePublicData =
+ require('../utils/firestore').getFirestoreStagePublicData;
+ const data = await mockGetFirestoreStagePublicData();
+ if (data !== undefined && data !== null) {
+ return {
+ exists: true,
+ data: () => data,
+ };
+ }
+ }
+ return {
+ exists: false,
+ data: () => ({}),
+ };
+ });
+
+ const mockDoc = jest.fn().mockImplementation((path: string) => {
+ return {
+ path,
+ collection: (col: string) => mockCollection(`${path}/${col}`),
+ doc: (doc: string) => mockDoc(`${path}/${doc}`),
+ set: (data: any, options: any) => mockSet(path, data, options),
+ delete: () => mockDelete(path),
+ get: () => mockGet(path),
+ };
+ });
+
+ const mockCollection = jest.fn().mockImplementation((path: string) => {
+ return {
+ path,
+ doc: (doc: string) => mockDoc(`${path}/${doc}`),
+ collection: (col: string) => mockCollection(`${path}/${col}`),
+ };
+ });
+
+ const mockRunTransaction = jest
+ .fn()
+ .mockImplementation(async (callback: (txn: any) => Promise) => {
+ const txn = {
+ get: (ref: any) => ref.get(),
+ create: (ref: any, data: any) => mockSet(ref.path, data, {}),
+ set: (ref: any, data: any, options: any) =>
+ mockSet(ref.path, data, options),
+ delete: (ref: any) => mockDelete(ref.path),
+ };
+ return callback(txn);
+ });
+
+ const mockFirestore = jest.fn().mockReturnValue({
+ collection: (col: string) => mockCollection(col),
+ doc: (doc: string) => mockDoc(doc),
+ runTransaction: mockRunTransaction,
+ });
+
+ return {
+ __esModule: true,
+ app: {
+ firestore: mockFirestore,
+ },
+ __mocks__: {
+ firestoreMock: mockFirestore,
+ collectionMock: mockCollection,
+ docMock: mockDoc,
+ setMock: mockSet,
+ deleteMock: mockDelete,
+ getMock: mockGet,
+ },
+ };
+});
+
+// Mock firebase-admin/firestore
+jest.mock('firebase-admin/firestore', () => {
+ return {
+ Timestamp: {
+ now: jest.fn().mockReturnValue({
+ seconds: 1234567890,
+ nanoseconds: 0,
+ }),
+ },
+ };
+});
+
+// Mock '../stages/chat.time' to prevent background timers and DB updates
+jest.mock('../stages/chat.time', () => ({
+ startTimeElapsed: jest.fn(),
+}));
+
+// Mock '../chat/chat.utils'
+jest.mock('../chat/chat.utils', () => ({
+ sendErrorPrivateChatMessage: jest.fn(),
+ updateParticipantReadyToEndChat: jest.fn(),
+ sendSystemChatMessage: jest.fn(),
+}));
+
+// Mock '../structured_prompt.utils' to bypass AI querying during internal functions execution
+jest.mock('../structured_prompt.utils', () => ({
+ getStructuredPromptConfig: jest.fn().mockResolvedValue({
+ chatSettings: {
+ initialMessage: 'Hello, I am the mediator.',
+ },
+ }),
+ getPromptFromConfig: jest.fn(),
+}));
+
+// Mock '../utils/firestore' functions
+jest.mock('../utils/firestore', () => {
+ // We need to construct a dummy mockDoc for path validation in triggers logs
+ const dummyMockDoc = (path: string): any => {
+ const {__mocks__} = require('../app');
+ return {
+ path,
+ collection: (col: string) => dummyMockDoc(`${path}/${col}`),
+ doc: (docName: string) => dummyMockDoc(`${path}/${docName}`),
+ set: (data: any, options: any) => __mocks__.setMock(path, data, options),
+ delete: () => __mocks__.deleteMock(path),
+ get: () => __mocks__.getMock(path),
+ };
+ };
+
+ return {
+ getFirestoreStage: jest.fn(),
+ getFirestoreStagePublicData: jest.fn(),
+ getFirestoreActiveParticipants: jest.fn(),
+ getFirestoreActiveMediators: jest.fn(),
+ getFirestoreParticipant: jest.fn(),
+ getGroupChatTriggerLogRef: (
+ expId: string,
+ cohortId: string,
+ stageId: string,
+ logId: string,
+ ) => {
+ return dummyMockDoc(
+ `experiments/${expId}/cohorts/${cohortId}/publicStageData/${stageId}/triggerLogs/${logId}`,
+ );
+ },
+ getPrivateChatTriggerLogRef: jest.fn(),
+ getFirestoreParticipantAnswerRef: jest.fn(),
+ getFirestorePublicStageChatMessages: jest.fn(),
+ getFirestorePrivateChatMessages: jest.fn(),
+ getAgentMediatorPrompt: jest.fn().mockResolvedValue({}),
+ getAgentParticipantPrompt: jest.fn().mockResolvedValue({}),
+ };
+});
+
+// Mock 'createAgentChatMessageFromPrompt' but keep other parts of '../chat/chat.agent'
+jest.mock('../chat/chat.agent', () => {
+ const originalModule = jest.requireActual('../chat/chat.agent');
+ return {
+ ...originalModule,
+ createAgentChatMessageFromPrompt: jest
+ .fn()
+ .mockImplementation(
+ async (
+ expId: string,
+ cohortId: string,
+ pIds: string[],
+ stageId: string,
+ triggerId: string,
+ user: any,
+ ) => {
+ return mockInternalCreateAgentChatMessage(
+ expId,
+ cohortId,
+ pIds,
+ stageId,
+ triggerId,
+ user,
+ );
+ },
+ ),
+ };
+});
+
+// Keep track of internal calls. By default it is a pure mock returning true.
+const mockInternalCreateAgentChatMessage = jest.fn().mockResolvedValue(true);
+
+// -----------------------------------------------------------------------------
+// IMPORTS AFTER MOCKS
+// -----------------------------------------------------------------------------
+
+import {onPublicChatMessageCreated} from './chat.triggers';
+import {sendInitialChatMessages} from '../chat/chat.agent';
+import {__mocks__} from '../app';
+import {
+ getFirestoreStage,
+ getFirestoreStagePublicData,
+ getFirestoreActiveParticipants,
+ getFirestoreActiveMediators,
+} from '../utils/firestore';
+
+const mockGetFirestoreStage = getFirestoreStage as jest.Mock;
+const mockGetFirestoreStagePublicData =
+ getFirestoreStagePublicData as jest.Mock;
+const mockGetFirestoreActiveParticipants =
+ getFirestoreActiveParticipants as jest.Mock;
+const mockGetFirestoreActiveMediators =
+ getFirestoreActiveMediators as jest.Mock;
+const mockShuffleWithSeed = shuffleWithSeed as unknown as jest.Mock;
+
+describe('Chat Triggers - Turn Taking Mechanics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockShuffleWithSeed.mockClear();
+ mockInternalCreateAgentChatMessage.mockReset();
+
+ // Configure default mock behavior for createAgentChatMessageFromPrompt
+ mockInternalCreateAgentChatMessage.mockResolvedValue(true);
+
+ // Default mock implementations
+ mockGetFirestoreStage.mockResolvedValue({
+ id: 'stage123',
+ kind: StageKind.CHAT,
+ isTurnBased: true,
+ discussions: [],
+ });
+
+ mockGetFirestoreActiveParticipants.mockResolvedValue([
+ {publicId: 'p1', privateId: 'priv1'},
+ {publicId: 'p2', privateId: 'priv2'},
+ {publicId: 'p3', privateId: 'priv3'},
+ ]);
+
+ mockGetFirestoreActiveMediators.mockResolvedValue([
+ {
+ publicId: 'm1',
+ privateId: 'priv-m1',
+ type: UserType.MEDIATOR,
+ agentConfig: {agentId: 'mediator-agent'},
+ },
+ ]);
+ });
+
+ // ---------------------------------------------------------------------------
+ // 1. TURN INITIALIZATION
+ // ---------------------------------------------------------------------------
+ describe('1. Turn Initialization', () => {
+ it('initializes turn order using shuffleWithSeed when currentTurnParticipantId is null', async () => {
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: null,
+ turnOrder: [],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p1',
+ message: 'Hello, let us start.',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert cycleIndex is 0 and seed string matches 'cohortId-cycleIndex'
+ expect(mockShuffleWithSeed).toHaveBeenCalledWith(
+ ['p1', 'p2', 'p3'],
+ 'cohort123-0',
+ );
+
+ // Shuffled order from deterministic seed is ['p3', 'p1', 'p2']
+ const expectedShuffled = mockShuffleWithSeed.mock.results[0].value;
+ const expectedTurnOrder = ['m1', ...expectedShuffled];
+
+ // Assert it updates Firestore with initialized turn order starting with mediator
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: expectedTurnOrder[0],
+ turnOrder: expectedTurnOrder,
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 2. TURN ADVANCEMENT
+ // ---------------------------------------------------------------------------
+ describe('2. Turn Advancement', () => {
+ it('advances turn to next participant when correct turn-holder sends a message', async () => {
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p1',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p1',
+ message: 'My turn!',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert that it advances from p1 to p2
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'p2',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+
+ // Assert that it does NOT trigger agent message since p2 is human (no agentConfig)
+ expect(mockInternalCreateAgentChatMessage).not.toHaveBeenCalled();
+ });
+
+ it('triggers next AI agent when turn advances to an agent participant', async () => {
+ // Make p3 an agent participant
+ mockGetFirestoreActiveParticipants.mockResolvedValue([
+ {publicId: 'p1', privateId: 'priv1'},
+ {publicId: 'p2', privateId: 'priv2'},
+ {
+ publicId: 'p3',
+ privateId: 'priv3',
+ agentConfig: {agentId: 'ai-agent-3'},
+ },
+ ]);
+
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p2',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p2',
+ message: 'Passing to agent p3',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert turn advances to p3
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'p3',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+
+ // Assert that AI agent for p3 is triggered
+ expect(mockInternalCreateAgentChatMessage).toHaveBeenCalledWith(
+ 'exp123',
+ 'cohort123',
+ ['priv3'],
+ 'stage123',
+ 'msg123',
+ expect.objectContaining({publicId: 'p3', privateId: 'priv3'}),
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 3. PREVENTION OF OUT-OF-TURN ADVANCEMENT
+ // ---------------------------------------------------------------------------
+ describe('3. Prevention of Out-of-Turn Advancement', () => {
+ it('does not advance turn when an out-of-turn participant sends a message', async () => {
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p2',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p3', // speaks out of turn (it is p2's turn)
+ message: 'Speaking out of turn!',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert that Firestore setMock was NOT called to advance the turn
+ expect(__mocks__.setMock).not.toHaveBeenCalled();
+ // Assert no AI agent or mediator was triggered
+ expect(mockInternalCreateAgentChatMessage).not.toHaveBeenCalled();
+ });
+
+ it("triggers the mediator if it was the mediator's turn but someone else speaks first", async () => {
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'm1', // mediator's turn
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p1', // speaks out of turn
+ message: 'Rushing in before mediator!',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert turn did not advance in database
+ expect(__mocks__.setMock).not.toHaveBeenCalled();
+
+ // Assert mediator is triggered to speak the initial message
+ expect(mockInternalCreateAgentChatMessage).toHaveBeenCalledWith(
+ 'exp123',
+ 'cohort123',
+ ['priv1', 'priv2', 'priv3'], // all participants for context
+ 'stage123',
+ '', // empty triggerChatId indicates initial message fallback
+ expect.objectContaining({publicId: 'm1', type: UserType.MEDIATOR}),
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 4. MEDIATOR AUTO-TRIGGERING
+ // ---------------------------------------------------------------------------
+ describe('4. Mediator Auto-Triggering', () => {
+ it('initializes turn-based chat and triggers mediator', async () => {
+ // Mock getFirestoreStagePublicData returning null (meaning uninitialized)
+ mockGetFirestoreStagePublicData.mockResolvedValue(null);
+
+ // In this test, we want to run the real createAgentChatMessageFromPrompt internally.
+ // By delegating to jest.requireActual in the mock implementation:
+ mockInternalCreateAgentChatMessage.mockImplementation(
+ async (
+ expId: string,
+ cohortId: string,
+ pIds: string[],
+ stageId: string,
+ triggerId: string,
+ user: any,
+ ) => {
+ const chatAgentModule = jest.requireActual('../chat/chat.agent');
+ return chatAgentModule.createAgentChatMessageFromPrompt(
+ expId,
+ cohortId,
+ pIds,
+ stageId,
+ triggerId,
+ user,
+ );
+ },
+ );
+
+ await sendInitialChatMessages(
+ 'exp123',
+ 'cohort123',
+ 'stage123',
+ 'priv1', // triggering participant
+ );
+
+ // Shuffled order from deterministic seed is ['p3', 'p1', 'p2']
+ const expectedShuffled = mockShuffleWithSeed.mock.results[0].value;
+ const expectedTurnOrder = ['m1', ...expectedShuffled];
+
+ // Assert initial state is stored in Firestore
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'm1',
+ turnOrder: expectedTurnOrder,
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+
+ // Assert mediator's message is constructed and saved to Firestore chats collection
+ const chatsCall = __mocks__.setMock.mock.calls.find((call: any) =>
+ call[0].includes('/chats/'),
+ );
+ expect(chatsCall).toBeDefined();
+ expect(chatsCall[1]).toEqual(
+ expect.objectContaining({
+ message: 'Hello, I am the mediator.',
+ senderId: 'm1',
+ type: UserType.MEDIATOR,
+ }),
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 5. CYCLE SHUFFLES ON END-OF-CYCLE
+ // ---------------------------------------------------------------------------
+ describe('5. Cycle Shuffles on End-of-Cycle', () => {
+ it('increments cycleIndex, shuffles with seed based on new cycleIndex, and restarts turn order', async () => {
+ // Let's set p3 as the current turn holder, which is the last person in turnOrder
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p3',
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p3', // last person sends a message
+ message: 'Wrapping up cycle 0!',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert cycleIndex incremented to 1 and shuffleWithSeed called with seed string 'cohort123-1'
+ expect(mockShuffleWithSeed).toHaveBeenCalledWith(
+ ['p1', 'p2', 'p3'],
+ 'cohort123-1',
+ );
+
+ const expectedShuffled = mockShuffleWithSeed.mock.results[0].value;
+ const expectedTurnOrder = ['m1', ...expectedShuffled];
+
+ // Assert turn order reset and database updated with cycleIndex = 1
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'm1', // mediator starts new cycle
+ turnOrder: expectedTurnOrder,
+ cycleIndex: 1,
+ },
+ {merge: true},
+ );
+
+ // Assert mediator is auto-triggered to start the new cycle since it is 'm1''s turn
+ expect(mockInternalCreateAgentChatMessage).toHaveBeenCalledWith(
+ 'exp123',
+ 'cohort123',
+ ['priv1', 'priv2', 'priv3'], // all participants for context
+ 'stage123',
+ 'msg123', // message that triggered end-of-cycle
+ expect.objectContaining({publicId: 'm1', type: UserType.MEDIATOR}),
+ );
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // 6. PARTICIPANT DROPOUT robustness
+ // ---------------------------------------------------------------------------
+ describe('6. Participant Dropout Robustness', () => {
+ it('Scenario 1: Auto-advance on active speaker dropout', async () => {
+ // p2 drops out (so they are NOT returned by getFirestoreActiveParticipants)
+ mockGetFirestoreActiveParticipants.mockResolvedValue([
+ {publicId: 'p1', privateId: 'priv1'},
+ {publicId: 'p3', privateId: 'priv3'},
+ ]);
+
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p2', // active turn holder dropped out
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p1', // p1 sends a message
+ message: 'Hello, is anyone there?',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Assert turnOrder is cleaned and currentTurnParticipantId advances to next active (p3)
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'p3',
+ turnOrder: ['m1', 'p1', 'p3'],
+ cycleIndex: 0,
+ },
+ {merge: true},
+ );
+
+ // Since the new turn holder is p3 (not p1 who sent the message), we return early and do NOT trigger further AI
+ expect(mockInternalCreateAgentChatMessage).not.toHaveBeenCalled();
+ });
+
+ it('Scenario 2: Recycle on all remaining speakers dropout', async () => {
+ // All remaining speakers (p2 and p3) drop out, leaving only p1
+ mockGetFirestoreActiveParticipants.mockResolvedValue([
+ {publicId: 'p1', privateId: 'priv1'},
+ ]);
+
+ mockGetFirestoreStagePublicData.mockResolvedValue({
+ id: 'stage123',
+ currentTurnParticipantId: 'p2', // active turn holder dropped out, no remaining speakers
+ turnOrder: ['m1', 'p1', 'p2', 'p3'],
+ cycleIndex: 0,
+ });
+
+ const chatMessage = {
+ id: 'msg123',
+ senderId: 'p1', // p1 sends a message
+ message: 'Hello, is anyone there?',
+ type: UserType.PARTICIPANT,
+ timestamp: {} as any,
+ } as ChatMessage;
+
+ const event = {
+ data: {
+ data: () => chatMessage,
+ exists: true,
+ },
+ params: {
+ experimentId: 'exp123',
+ cohortId: 'cohort123',
+ stageId: 'stage123',
+ chatId: 'msg123',
+ },
+ };
+
+ await onPublicChatMessageCreated.run(event as any);
+
+ // Shuffled active participants (only p1) for cycle 1
+ expect(mockShuffleWithSeed).toHaveBeenCalledWith(['p1'], 'cohort123-1');
+
+ const expectedShuffled = mockShuffleWithSeed.mock.results[0].value; // ['p1']
+ const expectedTurnOrder = ['m1', ...expectedShuffled]; // ['m1', 'p1']
+
+ // Assert turnOrder resets, cycleIndex increments, mediator is currentTurnParticipantId
+ expect(__mocks__.setMock).toHaveBeenCalledWith(
+ 'experiments/exp123/cohorts/cohort123/publicStageData/stage123',
+ {
+ currentTurnParticipantId: 'm1',
+ turnOrder: expectedTurnOrder,
+ cycleIndex: 1,
+ },
+ {merge: true},
+ );
+
+ // Assert mediator is auto-triggered!
+ expect(mockInternalCreateAgentChatMessage).toHaveBeenCalledWith(
+ 'exp123',
+ 'cohort123',
+ ['priv1'], // all remaining participants for context
+ 'stage123',
+ '', // empty triggerChatId indicates initial/reset mediator message
+ expect.objectContaining({publicId: 'm1', type: UserType.MEDIATOR}),
+ );
+ });
+ });
+});
diff --git a/functions/src/triggers/chat.triggers.ts b/functions/src/triggers/chat.triggers.ts
index 7fc1925b2..f2e8ea7e8 100644
--- a/functions/src/triggers/chat.triggers.ts
+++ b/functions/src/triggers/chat.triggers.ts
@@ -1,10 +1,17 @@
import {onDocumentCreated} from 'firebase-functions/v2/firestore';
import {
ChatMessage,
+ ChatStageConfig,
+ ChatStagePublicData,
ParticipantStatus,
StageKind,
UserType,
createParticipantProfileBase,
+ createSystemChatMessage,
+ shuffleWithSeed,
+ ChatPromptConfig,
+ ParticipantProfileExtended,
+ MediatorProfileExtended,
} from '@deliberation-lab/utils';
import {
getFirestoreActiveMediators,
@@ -12,10 +19,15 @@ import {
getFirestoreParticipant,
getFirestoreStage,
getFirestoreStagePublicData,
+ getFirestorePublicStageChatMessages,
} from '../utils/firestore';
-import {createAgentChatMessageFromPrompt} from '../chat/chat.agent';
+import {
+ createAgentChatMessageFromPrompt,
+ canSendAgentChatMessage,
+} from '../chat/chat.agent';
import {sendErrorPrivateChatMessage} from '../chat/chat.utils';
import {startTimeElapsed} from '../stages/chat.time';
+import {getStructuredPromptConfig} from '../structured_prompt.utils';
import {app} from '../app';
// ************************************************************************* //
@@ -51,7 +63,7 @@ export const onPublicChatMessageCreated = onDocumentCreated(
startTimeElapsed(
event.params.experimentId,
event.params.cohortId,
- publicStageData,
+ publicStageData as ChatStagePublicData,
);
break;
case StageKind.SALESPERSON:
@@ -71,43 +83,510 @@ export const onPublicChatMessageCreated = onDocumentCreated(
const allParticipantIds = allParticipants.map((p) => p.privateId);
- // Send agent mediator messages
- const mediators = await getFirestoreActiveMediators(
- event.params.experimentId,
- event.params.cohortId,
- stage.id,
- true,
- );
- await Promise.all(
- mediators.map((mediator) =>
- createAgentChatMessageFromPrompt(
- event.params.experimentId,
- event.params.cohortId,
- allParticipantIds, // Provide all participant IDs for full context
- stage.id,
- event.params.chatId,
- mediator,
+ const chatStage = stage as ChatStageConfig;
+ const chatPublicData = publicStageData as ChatStagePublicData;
+
+ if (chatStage.isTurnBased) {
+ const message = event.data?.data() as ChatMessage;
+ if (!message) return;
+ if (message.type === UserType.SYSTEM) return;
+ if (message.type === UserType.EXPERIMENTER) return;
+
+ const chatMessages = await getFirestorePublicStageChatMessages(
+ event.params.experimentId,
+ event.params.cohortId,
+ event.params.stageId,
+ );
+
+ const mediators = await getFirestoreActiveMediators(
+ event.params.experimentId,
+ event.params.cohortId,
+ stage.id,
+ true, // get AI mediators
+ );
+ const allMediatorIds = mediators.map((m) => m.publicId);
+
+ let nextTurnParticipantId: string | null = null;
+ let shouldTriggerAgent = false;
+ let nextTurnHolder: ParticipantProfileExtended | null | undefined = null;
+ let nextMediatorHolder: MediatorProfileExtended | null | undefined = null;
+ let wasTurnHolderDroppedOut = false;
+
+ const publicStageDataRef = app
+ .firestore()
+ .collection('experiments')
+ .doc(event.params.experimentId)
+ .collection('cohorts')
+ .doc(event.params.cohortId)
+ .collection('publicStageData')
+ .doc(event.params.stageId);
+
+ await app.firestore().runTransaction(async (transaction) => {
+ // Reset closure variables on every retry attempt to prevent retry contamination!
+ shouldTriggerAgent = false;
+ nextTurnHolder = null;
+ nextMediatorHolder = null;
+ wasTurnHolderDroppedOut = false;
+
+ const snapshot = await transaction.get(publicStageDataRef);
+ const chatPublicData = snapshot.data() as
+ | ChatStagePublicData
+ | undefined;
+ if (!chatPublicData) return;
+
+ let turnOrder = chatPublicData.turnOrder ?? [];
+ let currentTurnParticipantId = chatPublicData.currentTurnParticipantId;
+ let cycleIndex = chatPublicData.cycleIndex ?? 0;
+
+ // Get active IDs for validation and filtering. Filter out completed/booted/timed-out participants.
+ const activeParticipants = allParticipants.filter((p) => {
+ if (
+ p.currentCohortId !== undefined &&
+ p.currentCohortId !== event.params.cohortId
+ )
+ return false;
+ if (
+ p.currentStageId !== undefined &&
+ p.currentStageId !== event.params.stageId
+ )
+ return false;
+ const isExplicitlyInactive =
+ p.currentStatus !== undefined &&
+ p.currentStatus !== ParticipantStatus.IN_PROGRESS &&
+ p.currentStatus !== ParticipantStatus.ATTENTION_CHECK;
+ const isExplicitlyCompleted =
+ p.timestamps?.completedStages?.[event.params.stageId] !== undefined;
+ return !isExplicitlyInactive && !isExplicitlyCompleted;
+ });
+
+ if (activeParticipants.length === 0) {
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId: null,
+ turnOrder: [],
+ },
+ {merge: true},
+ );
+ return;
+ }
+
+ const allPublicParticipantIds = activeParticipants.map(
+ (p) => p.publicId,
+ );
+ const activeIds = [...allMediatorIds, ...allPublicParticipantIds];
+
+ // Filter turnOrder to only include currently active/non-dropped-out IDs
+ const originalTurnOrder = [...turnOrder];
+ const filteredTurnOrder = turnOrder.filter((id: string) =>
+ activeIds.includes(id),
+ );
+
+ const currentMediators = filteredTurnOrder.filter((id) =>
+ allMediatorIds.includes(id),
+ );
+ const missingMediators = allMediatorIds.filter(
+ (id) => !filteredTurnOrder.includes(id),
+ );
+ const currentParticipants = filteredTurnOrder.filter((id) =>
+ allPublicParticipantIds.includes(id),
+ );
+ const missingParticipants = allPublicParticipantIds.filter(
+ (id) => !filteredTurnOrder.includes(id),
+ );
+
+ turnOrder = [
+ ...currentMediators,
+ ...missingMediators,
+ ...currentParticipants,
+ ...missingParticipants,
+ ];
+
+ // If the current turn holder is no longer active (e.g., dropped out), auto-advance!
+ if (
+ currentTurnParticipantId &&
+ !activeIds.includes(currentTurnParticipantId)
+ ) {
+ wasTurnHolderDroppedOut = true;
+ const oldIndex = originalTurnOrder.indexOf(currentTurnParticipantId);
+ let nextActiveId: string | null = null;
+ if (oldIndex !== -1) {
+ for (let k = oldIndex + 1; k < originalTurnOrder.length; k++) {
+ const candidate = originalTurnOrder[k];
+ if (activeIds.includes(candidate)) {
+ nextActiveId = candidate;
+ break;
+ }
+ }
+ }
+
+ if (nextActiveId) {
+ currentTurnParticipantId = nextActiveId;
+ } else {
+ // No active participants left in this cycle, start a new cycle!
+ cycleIndex += 1;
+ let nextTurnOrder = [...turnOrder];
+ const hasMediators = allMediatorIds.length > 0;
+ if (hasMediators) {
+ const currentMediators = turnOrder.filter((id) =>
+ allMediatorIds.includes(id),
+ );
+ const missingMediators = allMediatorIds.filter(
+ (id) => !turnOrder.includes(id),
+ );
+ const shuffledMissingMediators =
+ missingMediators.length > 1
+ ? shuffleWithSeed(
+ missingMediators,
+ `${event.params.cohortId}-new-mediators-${cycleIndex}`,
+ )
+ : missingMediators;
+ const nextMediators = [
+ ...currentMediators,
+ ...shuffledMissingMediators,
+ ];
+
+ const hadMediators = turnOrder.some((id) =>
+ allMediatorIds.includes(id),
+ );
+ if (hadMediators) {
+ const seedString = `${event.params.cohortId}-${cycleIndex}`;
+ const shuffledParticipants = shuffleWithSeed(
+ allPublicParticipantIds,
+ seedString,
+ );
+ nextTurnOrder = [...nextMediators, ...shuffledParticipants];
+ } else {
+ const currentParticipants = turnOrder.filter((id) =>
+ allPublicParticipantIds.includes(id),
+ );
+ nextTurnOrder = [...nextMediators, ...currentParticipants];
+ }
+ }
+ turnOrder = nextTurnOrder;
+ currentTurnParticipantId = turnOrder[0] ?? null;
+ }
+ }
+
+ // 1. Initialize turn order if uninitialized or empty
+ if (!currentTurnParticipantId || turnOrder.length === 0) {
+ cycleIndex = 0;
+ const seedString = `${event.params.cohortId}-${cycleIndex}`;
+ const shuffledParticipants = shuffleWithSeed(
+ allPublicParticipantIds,
+ seedString,
+ );
+
+ // Shuffle mediator order when conversation begins (only if multiple)
+ const shuffledMediators =
+ allMediatorIds.length > 1
+ ? shuffleWithSeed(
+ allMediatorIds,
+ `${event.params.cohortId}-mediators`,
+ )
+ : allMediatorIds;
+
+ turnOrder = [...shuffledMediators, ...shuffledParticipants];
+ currentTurnParticipantId = turnOrder[0] ?? null;
+
+ const senderIsInitialTurnHolder =
+ message.senderId === currentTurnParticipantId;
+
+ transaction.set(
+ publicStageDataRef,
+ {
+ ...(senderIsInitialTurnHolder ? {} : {currentTurnParticipantId}),
+ turnOrder,
+ cycleIndex,
+ },
+ {merge: true},
+ );
+ }
+
+ // 2. If it is not the message sender's turn, do not advance
+ const isSenderActive =
+ mediators.some((m) => m.publicId === message.senderId) ||
+ allParticipants.some((p) => p.publicId === message.senderId);
+
+ const turnOrderChanged =
+ JSON.stringify(originalTurnOrder) !== JSON.stringify(turnOrder);
+ const turnHolderChanged =
+ chatPublicData?.currentTurnParticipantId !== currentTurnParticipantId;
+
+ if (message.senderId !== currentTurnParticipantId && isSenderActive) {
+ if (turnOrderChanged || turnHolderChanged) {
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId,
+ turnOrder,
+ cycleIndex,
+ },
+ {merge: true},
+ );
+
+ shouldTriggerAgent = true;
+ nextTurnHolder = allParticipants.find(
+ (p) => p.publicId === currentTurnParticipantId,
+ );
+ nextMediatorHolder = mediators.find(
+ (m) => m.publicId === currentTurnParticipantId,
+ );
+ }
+ return;
+ }
+
+ if (message.senderId === currentTurnParticipantId || !isSenderActive) {
+ // 3. Advance turn
+ nextTurnParticipantId = currentTurnParticipantId;
+ let nextIndex = turnOrder.indexOf(message.senderId);
+
+ // GRACEFUL OMISSION:
+ // If nextIndex is -1 (the sender completed/left/booted and is no longer in turnOrder),
+ // do NOT reset/scramble cycleIndex or turnOrder!
+ // Instead, gracefully set nextIndex to the current turn position or find the first active speaker!
+ if (nextIndex === -1) {
+ nextIndex = turnOrder.indexOf(currentTurnParticipantId);
+ if (nextIndex === -1) nextIndex = 0;
+ }
+
+ let attempts = 0;
+ let foundCandidate = false;
+
+ while (attempts < turnOrder.length) {
+ if (nextIndex === turnOrder.length - 1) {
+ // Cycle repeats!
+ cycleIndex += 1;
+
+ let nextTurnOrder = [...turnOrder];
+ const hasMediators = allMediatorIds.length > 0;
+ if (hasMediators) {
+ const currentMediators = turnOrder.filter((id) =>
+ allMediatorIds.includes(id),
+ );
+ const missingMediators = allMediatorIds.filter(
+ (id) => !turnOrder.includes(id),
+ );
+ const shuffledMissingMediators =
+ missingMediators.length > 1
+ ? shuffleWithSeed(
+ missingMediators,
+ `${event.params.cohortId}-new-mediators-${cycleIndex}`,
+ )
+ : missingMediators;
+ const nextMediators = [
+ ...currentMediators,
+ ...shuffledMissingMediators,
+ ];
+
+ const hadMediators = turnOrder.some((id) =>
+ allMediatorIds.includes(id),
+ );
+
+ if (hadMediators) {
+ const seedString = `${event.params.cohortId}-${cycleIndex}`;
+ const shuffledParticipants = shuffleWithSeed(
+ allPublicParticipantIds,
+ seedString,
+ );
+ nextTurnOrder = [...nextMediators, ...shuffledParticipants];
+ } else {
+ // Preserve the stable human ordering
+ const currentParticipants = turnOrder.filter((id) =>
+ allPublicParticipantIds.includes(id),
+ );
+ nextTurnOrder = [...nextMediators, ...currentParticipants];
+ }
+ }
+
+ turnOrder = nextTurnOrder;
+ nextIndex = 0;
+ } else {
+ nextIndex += 1;
+ }
+
+ nextTurnParticipantId = turnOrder[nextIndex];
+
+ const candidate =
+ allParticipants.find(
+ (p) => p.publicId === nextTurnParticipantId,
+ ) || mediators.find((m) => m.publicId === nextTurnParticipantId);
+
+ if (!candidate || !activeIds.includes(nextTurnParticipantId)) {
+ attempts++;
+ continue;
+ }
+
+ // If the candidate is an AI agent, check whether it can send a message
+ if (candidate.agentConfig) {
+ const promptConfig = (await getStructuredPromptConfig(
+ event.params.experimentId,
+ stage,
+ candidate,
+ )) as ChatPromptConfig | undefined;
+
+ if (
+ !promptConfig ||
+ !canSendAgentChatMessage(
+ candidate.publicId,
+ promptConfig.chatSettings,
+ chatMessages ?? [],
+ )
+ ) {
+ attempts++;
+ continue;
+ }
+ }
+
+ foundCandidate = true;
+ break;
+ }
+
+ if (foundCandidate) {
+ currentTurnParticipantId = nextTurnParticipantId;
+
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId,
+ turnOrder,
+ cycleIndex,
+ },
+ {merge: true},
+ );
+
+ shouldTriggerAgent = true;
+ nextTurnHolder = allParticipants.find(
+ (p) => p.publicId === currentTurnParticipantId,
+ );
+ nextMediatorHolder = mediators.find(
+ (m) => m.publicId === currentTurnParticipantId,
+ );
+ }
+ }
+ });
+
+ // 4. Outside Transaction: Trigger the next speaker or delete out-of-turn messages
+ const currentSnapshot = await publicStageDataRef.get();
+ const updatedPublicStageData = currentSnapshot.data() as
+ | ChatStagePublicData
+ | undefined;
+ const updatedTurnParticipantId =
+ updatedPublicStageData?.currentTurnParticipantId;
+ const updatedTurnOrder = updatedPublicStageData?.turnOrder ?? [];
+ const isSenderActive =
+ mediators.some((m) => m.publicId === message.senderId) ||
+ allParticipants.some((p) => p.publicId === message.senderId);
+
+ const originalTurnParticipantId =
+ publicStageData?.currentTurnParticipantId;
+
+ if (
+ message.senderId !== originalTurnParticipantId &&
+ message.senderId !== updatedTurnParticipantId &&
+ isSenderActive
+ ) {
+ // Delete out-of-turn messages
+ const messageRef = event.data?.ref;
+ if (messageRef) {
+ await messageRef.delete();
+ }
+
+ // Fallback to trigger initial agent message if first turn holder hasn't spoken
+ // and the conversation is empty (excluding system messages).
+ const nonSystemMessages = (chatMessages ?? []).filter(
+ (m) => m.type !== UserType.SYSTEM,
+ );
+ if (
+ nonSystemMessages.length === 0 &&
+ updatedTurnParticipantId &&
+ updatedTurnOrder.indexOf(updatedTurnParticipantId) === 0
+ ) {
+ const mediator = mediators.find(
+ (m) => m.publicId === updatedTurnParticipantId,
+ );
+ const participant = allParticipants.find(
+ (p) => p.publicId === updatedTurnParticipantId,
+ );
+ const agent =
+ mediator ?? (participant?.agentConfig ? participant : null);
+ if (agent) {
+ await createAgentChatMessageFromPrompt(
+ event.params.experimentId,
+ event.params.cohortId,
+ mediator
+ ? allParticipants.map((p) => p.privateId)
+ : [agent.privateId],
+ stage.id,
+ '', // empty triggerChatId indicates initial message
+ agent,
+ );
+ }
+ }
+ }
+
+ if (shouldTriggerAgent && updatedTurnParticipantId) {
+ const finalTriggerChatId = wasTurnHolderDroppedOut ? '' : message.id;
+ if (nextMediatorHolder) {
+ await createAgentChatMessageFromPrompt(
+ event.params.experimentId,
+ event.params.cohortId,
+ allParticipants.map((p) => p.privateId),
+ stage.id,
+ finalTriggerChatId,
+ nextMediatorHolder,
+ );
+ } else if (nextTurnHolder?.agentConfig) {
+ await createAgentChatMessageFromPrompt(
+ event.params.experimentId,
+ event.params.cohortId,
+ [nextTurnHolder.privateId],
+ stage.id,
+ finalTriggerChatId,
+ nextTurnHolder,
+ );
+ }
+ }
+ } else {
+ // Send agent mediator messages
+ const mediators = await getFirestoreActiveMediators(
+ event.params.experimentId,
+ event.params.cohortId,
+ stage.id,
+ true,
+ );
+ await Promise.all(
+ mediators.map((mediator) =>
+ createAgentChatMessageFromPrompt(
+ event.params.experimentId,
+ event.params.cohortId,
+ allParticipantIds, // Provide all participant IDs for full context
+ stage.id,
+ event.params.chatId,
+ mediator,
+ ),
),
- ),
- );
+ );
- // Send agent participant messages for agents who are still completing
- // the experiment
- const agentParticipants = allParticipants.filter(
- (p) => p.agentConfig && p.currentStatus === ParticipantStatus.IN_PROGRESS,
- );
- await Promise.all(
- agentParticipants.map((participant) =>
- createAgentChatMessageFromPrompt(
- event.params.experimentId,
- event.params.cohortId,
- [participant.privateId], // Pass agent's own ID as array
- stage.id,
- event.params.chatId,
- participant,
+ // Send agent participant messages for agents who are still completing
+ // the experiment
+ const agentParticipants = allParticipants.filter(
+ (p) =>
+ p.agentConfig && p.currentStatus === ParticipantStatus.IN_PROGRESS,
+ );
+ await Promise.all(
+ agentParticipants.map((participant) =>
+ createAgentChatMessageFromPrompt(
+ event.params.experimentId,
+ event.params.cohortId,
+ [participant.privateId], // Pass agent's own ID as array
+ stage.id,
+ event.params.chatId,
+ participant,
+ ),
),
- ),
- );
+ );
+ }
},
);
diff --git a/functions/src/triggers/participant.triggers.ts b/functions/src/triggers/participant.triggers.ts
index cf0cb0adc..42498c371 100644
--- a/functions/src/triggers/participant.triggers.ts
+++ b/functions/src/triggers/participant.triggers.ts
@@ -4,9 +4,14 @@ import {
} from 'firebase-functions/v2/firestore';
import {
+ ChatPromptConfig,
+ ChatStageConfig,
+ ChatStagePublicData,
ParticipantProfileExtended,
+ ParticipantStatus,
StageConfig,
StageKind,
+ shuffleWithSeed,
} from '@deliberation-lab/utils';
import {startAgentParticipant} from '../agent_participant.utils';
import {
@@ -14,7 +19,20 @@ import {
getParticipantRecord,
initializeParticipantStageAnswers,
} from '../participant.utils';
-import {getFirestoreParticipant} from '../utils/firestore';
+import {
+ getFirestoreActiveMediators,
+ getFirestoreActiveParticipants,
+ getFirestoreParticipant,
+ getFirestorePublicStageChatMessages,
+ getFirestoreStage,
+ getFirestoreStagePublicDataRef,
+} from '../utils/firestore';
+import {
+ canSendAgentChatMessage,
+ createAgentChatMessageFromPrompt,
+ skipTimedOutTurnBasedAgentTurn,
+} from '../chat/chat.agent';
+import {getStructuredPromptConfig} from '../structured_prompt.utils';
import {app} from '../app';
@@ -36,6 +54,223 @@ export const onParticipantCreation = onDocumentCreated(
},
);
+/** Advance a turn-based chat if the current speaker is no longer active. */
+async function advanceTurnBasedChatIfCurrentParticipantLeft(
+ experimentId: string,
+ before: ParticipantProfileExtended,
+ after: ParticipantProfileExtended,
+) {
+ if (
+ before.currentStatus === after.currentStatus &&
+ before.currentStageId === after.currentStageId &&
+ before.currentCohortId === after.currentCohortId
+ ) {
+ return;
+ }
+
+ const cohortId = before.currentCohortId;
+ const stageId = before.currentStageId;
+
+ const stage = await getFirestoreStage(experimentId, stageId);
+ if (stage?.kind !== StageKind.CHAT || !(stage as ChatStageConfig).isTurnBased)
+ return;
+
+ const publicStageDataRef = getFirestoreStagePublicDataRef(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+ const publicStageData = (await publicStageDataRef.get()).data() as
+ | ChatStagePublicData
+ | undefined;
+ const wasCurrentSpeaker =
+ publicStageData?.currentTurnParticipantId === before.publicId;
+
+ const [allActiveParticipants, activeMediators] = await Promise.all([
+ getFirestoreActiveParticipants(experimentId, cohortId, stageId, false),
+ getFirestoreActiveMediators(experimentId, cohortId, stageId, true),
+ ]);
+ const activeParticipants = allActiveParticipants.filter(
+ (p) => p.timestamps?.completedStages?.[stageId] === undefined,
+ );
+ const activeIds = new Set([
+ ...activeMediators.map((mediator) => mediator.publicId),
+ ...activeParticipants.map((participant) => participant.publicId),
+ ]);
+
+ const isAfterCompleted =
+ after.timestamps?.completedStages?.[stageId] !== undefined;
+
+ const isAfterActive =
+ after.currentCohortId === cohortId &&
+ after.currentStageId === stageId &&
+ (after.currentStatus === ParticipantStatus.IN_PROGRESS ||
+ after.currentStatus === ParticipantStatus.ATTENTION_CHECK) &&
+ !isAfterCompleted;
+
+ if (!isAfterActive) {
+ activeIds.delete(after.publicId);
+ }
+
+ if (isAfterActive && activeIds.has(after.publicId)) {
+ return;
+ }
+
+ const currentTurnParticipantId = await app
+ .firestore()
+ .runTransaction(async (transaction) => {
+ const snapshot = await transaction.get(publicStageDataRef);
+ const currentPublicStageData = snapshot.data() as
+ | ChatStagePublicData
+ | undefined;
+ if (!currentPublicStageData) {
+ return null;
+ }
+
+ if (activeParticipants.length === 0) {
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId: null,
+ turnOrder: [],
+ },
+ {merge: true},
+ );
+ return null;
+ }
+
+ const turnOrder = currentPublicStageData.turnOrder ?? [];
+ let nextTurnOrder = turnOrder.filter((id) => id !== before.publicId);
+
+ let currentTurnParticipantId =
+ currentPublicStageData.currentTurnParticipantId;
+ let cycleIndex = currentPublicStageData.cycleIndex ?? 0;
+
+ if (currentTurnParticipantId === before.publicId) {
+ const oldIndex = turnOrder.indexOf(before.publicId);
+ const filteredTurnOrder = nextTurnOrder;
+ const nextActiveId =
+ oldIndex === -1
+ ? filteredTurnOrder[0]
+ : turnOrder.slice(oldIndex + 1).find((id) => activeIds.has(id));
+
+ let nextTurnOrderNew = filteredTurnOrder;
+ currentTurnParticipantId = nextActiveId ?? null;
+ if (!currentTurnParticipantId && filteredTurnOrder.length > 0) {
+ cycleIndex += 1;
+ const allMediatorIds = activeMediators.map((m) => m.publicId);
+ const allPublicParticipantIds = activeParticipants.map(
+ (p) => p.publicId,
+ );
+ if (allMediatorIds.length > 0) {
+ const currentMediators = filteredTurnOrder.filter((id) =>
+ allMediatorIds.includes(id),
+ );
+ const missingMediators = allMediatorIds.filter(
+ (id) => !filteredTurnOrder.includes(id),
+ );
+ const shuffledMissingMediators =
+ missingMediators.length > 1
+ ? shuffleWithSeed(
+ missingMediators,
+ `${cohortId}-new-mediators-${cycleIndex}`,
+ )
+ : missingMediators;
+ const nextMediators = [
+ ...currentMediators,
+ ...shuffledMissingMediators,
+ ];
+ const hadMediators = filteredTurnOrder.some((id) =>
+ allMediatorIds.includes(id),
+ );
+ if (hadMediators) {
+ const shuffledParticipants = shuffleWithSeed(
+ allPublicParticipantIds,
+ `${cohortId}-${cycleIndex}`,
+ );
+ nextTurnOrderNew = [...nextMediators, ...shuffledParticipants];
+ } else {
+ const currentParticipants = filteredTurnOrder.filter((id) =>
+ allPublicParticipantIds.includes(id),
+ );
+ nextTurnOrderNew = [...nextMediators, ...currentParticipants];
+ }
+ }
+ currentTurnParticipantId = nextTurnOrderNew[0] ?? null;
+ nextTurnOrder = nextTurnOrderNew;
+ }
+ }
+
+ transaction.set(
+ publicStageDataRef,
+ {
+ currentTurnParticipantId,
+ turnOrder: nextTurnOrder,
+ cycleIndex,
+ },
+ {merge: true},
+ );
+
+ return currentTurnParticipantId;
+ });
+
+ if (!currentTurnParticipantId || !wasCurrentSpeaker) return;
+
+ const mediator = activeMediators.find(
+ (m) => m.publicId === currentTurnParticipantId,
+ );
+ const participant = activeParticipants.find(
+ (p) => p.publicId === currentTurnParticipantId,
+ );
+ const agent = mediator ?? (participant?.agentConfig ? participant : null);
+ if (!agent) return;
+
+ const chatMessages = await getFirestorePublicStageChatMessages(
+ experimentId,
+ cohortId,
+ stageId,
+ );
+ const triggerChatId = chatMessages[chatMessages.length - 1]?.id ?? '';
+
+ // If the next agent is ineligible (e.g. maxResponses hit), advance past them
+ // rather than silently failing and leaving the turn frozen.
+ const promptConfig = (await getStructuredPromptConfig(
+ experimentId,
+ stage,
+ agent,
+ )) as ChatPromptConfig | undefined;
+ if (
+ !promptConfig ||
+ !canSendAgentChatMessage(
+ agent.publicId,
+ promptConfig.chatSettings,
+ chatMessages,
+ )
+ ) {
+ const skipReason = !promptConfig
+ ? 'a missing prompt configuration'
+ : 'reaching the response limit';
+ await skipTimedOutTurnBasedAgentTurn(
+ experimentId,
+ cohortId,
+ stage as ChatStageConfig,
+ triggerChatId,
+ agent,
+ skipReason,
+ );
+ return;
+ }
+
+ await createAgentChatMessageFromPrompt(
+ experimentId,
+ cohortId,
+ mediator ? activeParticipants.map((p) => p.privateId) : [agent.privateId],
+ stageId,
+ triggerChatId,
+ agent,
+ );
+}
+
/** Trigger when a disconnected participant reconnects. */
export const onParticipantReconnect = onDocumentUpdated(
{
@@ -49,6 +284,27 @@ export const onParticipantReconnect = onDocumentUpdated(
const before = event.data.before.data() as ParticipantProfileExtended;
const after = event.data.after.data() as ParticipantProfileExtended;
+ const stageDoc = app
+ .firestore()
+ .collection('experiments')
+ .doc(experimentId)
+ .collection('stages')
+ .doc(before.currentStageId);
+ const stageConfig = (await stageDoc.get()).data() as
+ | StageConfig
+ | undefined;
+
+ if (
+ stageConfig?.kind === StageKind.CHAT &&
+ (stageConfig as ChatStageConfig).isTurnBased
+ ) {
+ await advanceTurnBasedChatIfCurrentParticipantLeft(
+ experimentId,
+ before,
+ after,
+ );
+ }
+
// Check if participant reconnected
if (!before.connected && after.connected) {
const firestore = app.firestore();
diff --git a/scripts/deliberate_lab/types.py b/scripts/deliberate_lab/types.py
index 6db5509b7..9addd6b3a 100644
--- a/scripts/deliberate_lab/types.py
+++ b/scripts/deliberate_lab/types.py
@@ -978,6 +978,7 @@ class ChatStageConfig(BaseModel):
timeLimitInMinutes: Annotated[int | None, Field(ge=1)] = None
timeMinimumInMinutes: Annotated[int | None, Field(ge=1)] = None
discussions: list[DefaultChatDiscussion | CompareChatDiscussion]
+ isTurnBased: bool | None = None
class RankingStageConfig(
@@ -1353,6 +1354,8 @@ class ChatPromptConfig(BaseModel):
| ProfileInfoPromptItem
| ProfileContextPromptItem
| StageContextPromptItem
+ | ChatMediatorInstructionsPromptItem
+ | ChatParticipantInstructionsPromptItem
| PromptItemGroup
]
includeScaffoldingInPrompt: bool | None = None
@@ -1407,6 +1410,24 @@ class StageContextPromptItem(BaseModel):
condition: ComparisonCondition | ConditionGroup | None = None
+class ChatMediatorInstructionsPromptItem(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ populate_by_name=True,
+ )
+ type: Literal["CHAT_MEDIATOR_INSTRUCTIONS"] = "CHAT_MEDIATOR_INSTRUCTIONS"
+ condition: ComparisonCondition | ConditionGroup | None = None
+
+
+class ChatParticipantInstructionsPromptItem(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ populate_by_name=True,
+ )
+ type: Literal["CHAT_PARTICIPANT_INSTRUCTIONS"] = "CHAT_PARTICIPANT_INSTRUCTIONS"
+ condition: ComparisonCondition | ConditionGroup | None = None
+
+
class PromptItemGroup(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -1419,6 +1440,8 @@ class PromptItemGroup(BaseModel):
| ProfileInfoPromptItem
| ProfileContextPromptItem
| StageContextPromptItem
+ | ChatMediatorInstructionsPromptItem
+ | ChatParticipantInstructionsPromptItem
| PromptItemGroup
]
shuffleConfig: ShuffleConfig | None = None
@@ -1484,6 +1507,8 @@ class GenericPromptConfig(BaseModel):
| ProfileInfoPromptItem
| ProfileContextPromptItem
| StageContextPromptItem
+ | ChatMediatorInstructionsPromptItem
+ | ChatParticipantInstructionsPromptItem
| PromptItemGroup
]
includeScaffoldingInPrompt: bool | None = None
diff --git a/utils/src/prompt.validation.ts b/utils/src/prompt.validation.ts
index 13239a26d..539899cb9 100644
--- a/utils/src/prompt.validation.ts
+++ b/utils/src/prompt.validation.ts
@@ -30,6 +30,8 @@ export const PromptItemTypeData = Type.Union(
Type.Literal('PROFILE_CONTEXT'),
Type.Literal('STAGE_CONTEXT'),
Type.Literal('GROUP'),
+ Type.Literal('CHAT_MEDIATOR_INSTRUCTIONS'),
+ Type.Literal('CHAT_PARTICIPANT_INSTRUCTIONS'),
],
{$id: 'PromptItemType'},
);
@@ -86,6 +88,24 @@ export const StageContextPromptItemData = Type.Object(
{$id: 'StageContextPromptItem', ...strict},
);
+/** Chat mediator behavior instructions prompt item */
+export const ChatMediatorInstructionsPromptItemData = Type.Object(
+ {
+ type: Type.Literal('CHAT_MEDIATOR_INSTRUCTIONS'),
+ ...BasePromptItemFields,
+ },
+ {$id: 'ChatMediatorInstructionsPromptItem', ...strict},
+);
+
+/** Chat participant behavior instructions prompt item */
+export const ChatParticipantInstructionsPromptItemData = Type.Object(
+ {
+ type: Type.Literal('CHAT_PARTICIPANT_INSTRUCTIONS'),
+ ...BasePromptItemFields,
+ },
+ {$id: 'ChatParticipantInstructionsPromptItem', ...strict},
+);
+
/** Prompt item group (recursive).
* Uses Type.Recursive to properly handle nested groups in items array.
*/
@@ -101,6 +121,8 @@ export const PromptItemGroupData = Type.Recursive(
ProfileInfoPromptItemData,
ProfileContextPromptItemData,
StageContextPromptItemData,
+ ChatMediatorInstructionsPromptItemData,
+ ChatParticipantInstructionsPromptItemData,
This,
]),
),
@@ -118,6 +140,8 @@ export const PromptItemData = Type.Union([
ProfileInfoPromptItemData,
ProfileContextPromptItemData,
StageContextPromptItemData,
+ ChatMediatorInstructionsPromptItemData,
+ ChatParticipantInstructionsPromptItemData,
PromptItemGroupData,
]);
diff --git a/utils/src/stages/chat_stage.manager.ts b/utils/src/stages/chat_stage.manager.ts
index d430278f6..cbe866a53 100644
--- a/utils/src/stages/chat_stage.manager.ts
+++ b/utils/src/stages/chat_stage.manager.ts
@@ -6,14 +6,16 @@ import {
PROFILE_SET_NATURE_ID,
} from '../profile_sets';
import {
+ ChatParticipantInstructionsPromptItem,
+ DEFAULT_AGENT_PARTICIPANT_PROMPT_INSTRUCTIONS,
MediatorPromptConfig,
ParticipantPromptConfig,
- createDefaultParticipantPrompt,
+ PromptItemType,
+ createDefaultStageContextPromptItem,
+ createTextPromptItem,
} from '../structured_prompt';
import {ChatStageConfig} from './chat_stage';
import {
- DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS,
- DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT,
createChatPromptConfig,
createDefaultMediatorGroupChatPrompt,
getChatPromptMessageHistory,
@@ -84,9 +86,16 @@ export class GroupChatStageHandler extends BaseStageHandler {
stage: ChatStageConfig,
): ParticipantPromptConfig | undefined {
return createChatPromptConfig(stage.id, StageKind.CHAT, {
- prompt: createDefaultParticipantPrompt(
- DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT,
- ),
+ prompt: [
+ createTextPromptItem(DEFAULT_AGENT_PARTICIPANT_PROMPT_INSTRUCTIONS),
+ createTextPromptItem('--- Participant description ---'),
+ {type: PromptItemType.PROFILE_INFO},
+ {type: PromptItemType.PROFILE_CONTEXT},
+ createDefaultStageContextPromptItem(''),
+ {
+ type: PromptItemType.CHAT_PARTICIPANT_INSTRUCTIONS,
+ } as ChatParticipantInstructionsPromptItem,
+ ],
});
}
}
diff --git a/utils/src/stages/chat_stage.prompts.ts b/utils/src/stages/chat_stage.prompts.ts
index 993081e2d..42fc68a32 100644
--- a/utils/src/stages/chat_stage.prompts.ts
+++ b/utils/src/stages/chat_stage.prompts.ts
@@ -13,6 +13,7 @@ import {
makeStructuredOutputPrompt,
} from '../structured_output';
import {
+ ChatMediatorInstructionsPromptItem,
ChatPromptConfig,
PromptItem,
PromptItemType,
@@ -33,20 +34,21 @@ import {getBaseStagePrompt} from './stage.prompts';
// ************************************************************************* //
// CONSTANTS //
// ************************************************************************* //
+export const DEFAULT_MEDIATOR_CHAT_STYLE_INSTRUCTIONS = `Follow any persona context or instructions carefully. If none are given, respond in short, natural sentences (1–2 per turn).`;
+
+export const DEFAULT_MEDIATOR_CHAT_FREQUENCY_INSTRUCTIONS = `Adjust your response frequency based on group size: respond less often in groups with multiple participants so that all have a chance to speak.`;
+
/** Default group chat stage instructions to provide to mediator in prompts. */
-export const DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS = `Follow any persona context or instructions carefully. If none are given, respond in short, natural sentences (1–2 per turn). Adjust your response frequency based on group size: respond less often in groups with multiple participants so that all have a chance to speak.`;
+export const DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS = `${DEFAULT_MEDIATOR_CHAT_STYLE_INSTRUCTIONS} ${DEFAULT_MEDIATOR_CHAT_FREQUENCY_INSTRUCTIONS}`;
+
+export const DEFAULT_MEDIATOR_GROUP_CHAT_TURN_TAKING_PROMPT_INSTRUCTIONS =
+ DEFAULT_MEDIATOR_CHAT_STYLE_INSTRUCTIONS;
/** Mediator prompt text for private chat.*/
export const DEFAULT_AGENT_PRIVATE_MEDIATOR_CHAT_PROMPT = `You are an agent who is chatting with a participant. Your task is to ensure that the participant's questions are answered.`;
/** Participant prompt text for group chat. */
-export const DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT = `You are participating in a live group chat as your persona.
-
-First, react: read the last 1-2 messages and ask yourself how this specific person would feel in this moment. Are they amused? Annoyed? Curious? Uncertain? Let that reaction drive your response.
-
-Then decide whether to actually send a message. Not every message in a group chat deserves a reply — sometimes you'd scroll past. If yes, write it. If no, stay silent.
-
-Rules for how to write your message:
+export const DEFAULT_AGENT_PARTICIPANT_CHAT_STYLE_INSTRUCTIONS = `Rules for how to write your message:
- Let your persona determine how you open. Some people jump straight to their opinion; others react to what was just said first ("yeah but—", "wait, really?", "I mean, kind of"). Don't default to a thesis-statement opener just because it's the easiest thing to write — ask what *this person* would actually do.
- Keep it short: 1-3 sentences as a default. Never write a paragraph. Real people in group chats don't write essays.
- Verbosity is a tendency, not a rule. A terse persona is usually brief — but if something genuinely provokes them, they say more. A verbose persona usually elaborates — but sometimes a short reaction is all they have. Let the moment determine it.
@@ -55,6 +57,20 @@ Rules for how to write your message:
- Match your persona's conversational style: some people always have something substantive to add, others often just signal agreement or confusion. Not every response needs to make a new point — but only write a low-substance reply if that genuinely fits how your persona communicates.
- Stay in character throughout. Do not summarize, explain your reasoning, or step outside the persona.`;
+export const DEFAULT_AGENT_PARTICIPANT_CHAT_PROMPT = `You are participating in a live group chat as your persona.
+
+First, react: read the last 1-2 messages and ask yourself how this specific person would feel in this moment. Are they amused? Annoyed? Curious? Uncertain? Let that reaction drive your response.
+
+Then decide whether to actually send a message. Not every message in a group chat deserves a reply — sometimes you'd scroll past. If yes, write it. If no, stay silent.
+
+${DEFAULT_AGENT_PARTICIPANT_CHAT_STYLE_INSTRUCTIONS}`;
+
+export const DEFAULT_AGENT_PARTICIPANT_CHAT_TURN_TAKING_PROMPT = `You are participating in a live group chat as your persona. It is your turn to write a message.
+
+First, react: read the last 1-2 messages and ask yourself how this specific person would feel in this moment. Are they amused? Annoyed? Curious? Uncertain? Let that reaction drive your response. Write a message.
+
+${DEFAULT_AGENT_PARTICIPANT_CHAT_STYLE_INSTRUCTIONS}`;
+
/** Hardcoded text used in stage display of chat transcript. */
export const CHAT_PROMPT_TRANSCRIPT_EXPLANATION = `Below is the transcript of your discussion. Messages are shown in chronological order; new messages appear at the bottom. Each message / turn follows the format: (HH:MM) Name: message.`;
@@ -151,7 +167,9 @@ export function createDefaultMediatorGroupChatPrompt(
),
{type: PromptItemType.PROFILE_INFO},
{type: PromptItemType.PROFILE_CONTEXT},
- createTextPromptItem(DEFAULT_MEDIATOR_GROUP_CHAT_PROMPT_INSTRUCTIONS),
+ {
+ type: PromptItemType.CHAT_MEDIATOR_INSTRUCTIONS,
+ } as ChatMediatorInstructionsPromptItem,
createDefaultStageContextPromptItem(stageId),
createTextPromptItem(text),
];
diff --git a/utils/src/stages/chat_stage.ts b/utils/src/stages/chat_stage.ts
index 17b283792..41e773f84 100644
--- a/utils/src/stages/chat_stage.ts
+++ b/utils/src/stages/chat_stage.ts
@@ -1,4 +1,3 @@
-import {Timestamp} from 'firebase/firestore';
import {generateId, UnifiedTimestamp} from '../shared';
import {
BaseStageConfig,
@@ -8,10 +7,6 @@ import {
createStageTextConfig,
createStageProgressConfig,
} from './stage';
-import {
- ParticipantProfileBase,
- createParticipantProfileBase,
-} from '../participant';
/** Group chat stage types and functions. */
// TODO: Rename file to group_chat_stage.ts
@@ -31,6 +26,7 @@ export interface ChatStageConfig extends BaseStageConfig {
// TODO: Migrate to seconds for internal storage to avoid fractional-minute ambiguity.
timeLimitInMinutes: number | null; // Maximum duration in minutes (integer), or null if no limit.
timeMinimumInMinutes: number | null; // Minimum time participants must stay in minutes (integer), or null if no minimum.
+ isTurnBased?: boolean; // Whether the conversation is turn-based
}
/** Chat discussion. */
@@ -99,6 +95,9 @@ export interface ChatStagePublicData extends BaseStagePublicData {
discussionCheckpointTimestamp: UnifiedTimestamp | null;
// If the end timestamp is not null, the conversation has ended.
discussionEndTimestamp: UnifiedTimestamp | null;
+ currentTurnParticipantId?: string | null; // ID of the participant whose turn it is
+ turnOrder?: string[]; // Array of participant IDs defining the turn order
+ cycleIndex?: number; // Counter to track turn cycles for seeded random
}
// ************************************************************************* //
@@ -120,6 +119,7 @@ export function createChatStage(
discussions: config.discussions ?? [],
timeLimitInMinutes: config.timeLimitInMinutes ?? null,
timeMinimumInMinutes: config.timeMinimumInMinutes ?? null,
+ isTurnBased: config.isTurnBased ?? false,
};
}
@@ -172,5 +172,8 @@ export function createChatStagePublicData(
discussionStartTimestamp: null,
discussionCheckpointTimestamp: null,
discussionEndTimestamp: null,
+ currentTurnParticipantId: null,
+ turnOrder: [],
+ cycleIndex: 0,
};
}
diff --git a/utils/src/stages/chat_stage.validation.ts b/utils/src/stages/chat_stage.validation.ts
index 7695055bf..d3d8fb604 100644
--- a/utils/src/stages/chat_stage.validation.ts
+++ b/utils/src/stages/chat_stage.validation.ts
@@ -69,6 +69,7 @@ export const ChatStageConfigData = Type.Composite(
Type.Union([Type.Integer({minimum: 1}), Type.Null()]),
),
discussions: Type.Array(ChatDiscussionData),
+ isTurnBased: Type.Optional(Type.Boolean()),
},
strict,
),
diff --git a/utils/src/structured_prompt.ts b/utils/src/structured_prompt.ts
index cc4a77308..648426f93 100644
--- a/utils/src/structured_prompt.ts
+++ b/utils/src/structured_prompt.ts
@@ -96,7 +96,9 @@ export type PromptItem =
| ProfileContextPromptItem
| ProfileInfoPromptItem
| StageContextPromptItem
- | PromptItemGroup;
+ | PromptItemGroup
+ | ChatMediatorInstructionsPromptItem
+ | ChatParticipantInstructionsPromptItem;
export interface BasePromptItem {
type: PromptItemType;
@@ -117,6 +119,10 @@ export enum PromptItemType {
STAGE_CONTEXT = 'STAGE_CONTEXT',
// Group of prompt items
GROUP = 'GROUP',
+ // Chat-specific instruction components — resolved at prompt-build time
+ // to the turn-based or free-form variant based on stage config.
+ CHAT_MEDIATOR_INSTRUCTIONS = 'CHAT_MEDIATOR_INSTRUCTIONS',
+ CHAT_PARTICIPANT_INSTRUCTIONS = 'CHAT_PARTICIPANT_INSTRUCTIONS',
}
export interface TextPromptItem extends BasePromptItem {
@@ -166,6 +172,16 @@ export interface PromptItemGroup extends BasePromptItem {
shuffleConfig?: ShuffleConfig;
}
+/** Group chat mediator behavior instructions, resolved at build time. */
+export interface ChatMediatorInstructionsPromptItem extends BasePromptItem {
+ type: PromptItemType.CHAT_MEDIATOR_INSTRUCTIONS;
+}
+
+/** Group chat participant behavior instructions, resolved at build time. */
+export interface ChatParticipantInstructionsPromptItem extends BasePromptItem {
+ type: PromptItemType.CHAT_PARTICIPANT_INSTRUCTIONS;
+}
+
// ****************************************************************************
// FUNCTIONS
// ****************************************************************************