diff --git a/functions/src/chat/chat.agent.test.ts b/functions/src/chat/chat.agent.test.ts new file mode 100644 index 000000000..fceeba50e --- /dev/null +++ b/functions/src/chat/chat.agent.test.ts @@ -0,0 +1,497 @@ +import { + BaseStageConfig, + ModelResponseStatus, + StageKind, + UserType, +} from '@deliberation-lab/utils'; + +// ---- Mocks ---- + +jest.mock('../app', () => { + const setMock = jest.fn().mockResolvedValue(undefined); + const getMock = jest.fn().mockResolvedValue({exists: false}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chain: Record = { + get: getMock, + set: setMock, + collection: jest.fn(), + doc: jest.fn(), + }; + chain.collection.mockReturnValue(chain); + chain.doc.mockReturnValue(chain); + return { + __esModule: true, + app: {firestore: jest.fn().mockReturnValue(chain)}, + __mocks__: {setMock, getMock}, + }; +}); + +jest.mock('../agent.utils', () => { + const processModelResponseMock = jest.fn(); + return { + __esModule: true, + processModelResponse: (...args: unknown[]) => + processModelResponseMock(...args), + __mocks__: {processModelResponseMock}, + }; +}); + +jest.mock('../structured_prompt.utils', () => { + const getPromptFromConfigMock = jest.fn().mockResolvedValue('test prompt'); + const getStructuredPromptConfigMock = jest.fn(); + return { + __esModule: true, + getPromptFromConfig: (...args: unknown[]) => + getPromptFromConfigMock(...args), + getStructuredPromptConfig: (...args: unknown[]) => + getStructuredPromptConfigMock(...args), + __mocks__: {getPromptFromConfigMock, getStructuredPromptConfigMock}, + }; +}); + +jest.mock('../variables.utils', () => ({ + __esModule: true, + resolveStringWithVariables: jest.fn(async (s: string) => s), +})); + +jest.mock('./message_converter.utils', () => ({ + __esModule: true, + convertChatToMessages: jest.fn().mockReturnValue([]), + shouldUseMessageFormat: jest.fn().mockReturnValue(false), + MessageRole: {SYSTEM: 'system', USER: 'user', ASSISTANT: 'assistant'}, +})); + +jest.mock('../chat/chat.utils', () => ({ + __esModule: true, + updateParticipantReadyToEndChat: jest.fn(), +})); + +jest.mock('../utils/firestore', () => { + const getFirestoreStageMock = jest.fn(); + const getFirestorePrivateChatMessagesMock = jest.fn().mockResolvedValue([]); + const getFirestorePublicStageChatMessagesMock = jest + .fn() + .mockResolvedValue([]); + const getExperimenterDataFromExperimentMock = jest.fn().mockResolvedValue({ + apiKeys: {geminiKey: 'test-key'}, + }); + const getFirestoreActiveMediatorsMock = jest.fn().mockResolvedValue([]); + const getFirestoreActiveParticipantsMock = jest.fn().mockResolvedValue([]); + const getGroupChatTriggerLogRefMock = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({exists: false}), + set: jest.fn().mockResolvedValue(undefined), + }); + const getPrivateChatTriggerLogRefMock = jest.fn().mockReturnValue({ + get: jest.fn().mockResolvedValue({exists: false}), + set: jest.fn().mockResolvedValue(undefined), + }); + const participantAnswerDocSetMock = jest.fn().mockResolvedValue(undefined); + const getFirestoreParticipantAnswerRefMock = jest.fn().mockReturnValue({ + set: participantAnswerDocSetMock, + }); + const getFirestoreStagePublicDataMock = jest.fn().mockResolvedValue(null); + return { + __esModule: true, + getFirestoreStage: (...args: unknown[]) => getFirestoreStageMock(...args), + getFirestorePrivateChatMessages: (...args: unknown[]) => + getFirestorePrivateChatMessagesMock(...args), + getFirestorePublicStageChatMessages: (...args: unknown[]) => + getFirestorePublicStageChatMessagesMock(...args), + getExperimenterDataFromExperiment: (...args: unknown[]) => + getExperimenterDataFromExperimentMock(...args), + getFirestoreActiveMediators: (...args: unknown[]) => + getFirestoreActiveMediatorsMock(...args), + getFirestoreActiveParticipants: (...args: unknown[]) => + getFirestoreActiveParticipantsMock(...args), + getGroupChatTriggerLogRef: (...args: unknown[]) => + getGroupChatTriggerLogRefMock(...args), + getPrivateChatTriggerLogRef: (...args: unknown[]) => + getPrivateChatTriggerLogRefMock(...args), + getFirestoreParticipantAnswerRef: (...args: unknown[]) => + getFirestoreParticipantAnswerRefMock(...args), + getFirestoreStagePublicData: (...args: unknown[]) => + getFirestoreStagePublicDataMock(...args), + __mocks__: { + getFirestoreStageMock, + getFirestorePrivateChatMessagesMock, + getFirestorePublicStageChatMessagesMock, + getExperimenterDataFromExperimentMock, + getFirestoreActiveMediatorsMock, + participantAnswerDocSetMock, + getFirestoreParticipantAnswerRefMock, + }, + }; +}); + +jest.mock('../utils/storage', () => ({ + __esModule: true, + getChatMessageStoragePath: jest.fn(), + uploadModelResponseFiles: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../log.utils', () => ({ + __esModule: true, + updateModelLogFiles: jest.fn(), +})); + +import { + AgentMessageResult, + createAgentChatMessageFromPrompt, + getAgentChatMessage, +} from './chat.agent'; +import {__mocks__ as agentUtilsMocks} from '../agent.utils'; +import {__mocks__ as promptMocks} from '../structured_prompt.utils'; +import {__mocks__ as firestoreMocks} from '../utils/firestore'; + +// ---- Shared helpers ---- + +function createMockUser(overrides: Record = {}) { + return { + type: UserType.MEDIATOR, + publicId: 'mediator-1', + privateId: 'priv-mediator-1', + agentConfig: { + agentId: 'agent-1', + modelSettings: {model: 'gemini-pro'}, + }, + ...overrides, + }; +} + +function createMockStage(overrides: Partial = {}) { + return { + id: 'stage-1', + kind: StageKind.PRIVATE_CHAT, + ...overrides, + }; +} + +/** Set up mocks so createAgentChatMessageFromPrompt reaches getAgentChatMessage. */ +function setupBasicMocks(stageOverrides: Partial = {}) { + const stage = createMockStage(stageOverrides); + firestoreMocks.getFirestoreStageMock.mockResolvedValue(stage); + promptMocks.getStructuredPromptConfigMock.mockResolvedValue({ + chatSettings: { + maxResponses: null, + minMessagesBeforeResponding: 0, + canSelfTriggerCalls: true, + wordsPerMinute: 0, + }, + generationConfig: {}, + structuredOutputConfig: undefined, + }); + return stage; +} + +/** Build a successful model response with optional structured output. */ +function makeModelResponse( + overrides: Partial<{ + status: ModelResponseStatus; + text: string | null; + reasoning: string | null; + rawResponse: string | null; + files: unknown[] | null; + }> = {}, +) { + return { + response: { + status: ModelResponseStatus.OK, + text: 'Hello!', + reasoning: null, + rawResponse: null, + files: null, + ...overrides, + }, + logId: 'log-1', + }; +} + +// ---- Tests ---- + +describe('createAgentChatMessageFromPrompt return type', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 'error' when user has no agentConfig", async () => { + const user = createMockUser({agentConfig: null}); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + user, + ); + + expect(result).toBe(AgentMessageResult.ERROR); + }); + + it("returns 'error' when stage is not found", async () => { + firestoreMocks.getFirestoreStageMock.mockResolvedValue(null); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + createMockUser(), + ); + + expect(result).toBe(AgentMessageResult.ERROR); + }); + + it("returns 'declined' when canSendAgentChatMessage returns false (maxResponses hit)", async () => { + setupBasicMocks(); + // Make chat history show the agent already sent a message, with maxResponses: 1 + promptMocks.getStructuredPromptConfigMock.mockResolvedValue({ + chatSettings: { + maxResponses: 1, + minMessagesBeforeResponding: 0, + canSelfTriggerCalls: true, + wordsPerMinute: 0, + }, + generationConfig: {}, + structuredOutputConfig: undefined, + }); + firestoreMocks.getFirestorePrivateChatMessagesMock.mockResolvedValue([ + {senderId: 'mediator-1', message: 'hi'}, + ]); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + createMockUser(), + ); + + expect(result).toBe(AgentMessageResult.DECLINED); + }); + + it("returns 'declined' when structured output shouldRespond is false", async () => { + setupBasicMocks(); + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeModelResponse({ + text: JSON.stringify({ + shouldRespond: false, + response: '', + explanation: '', + readyToEndChat: false, + }), + }), + ); + + // Enable structured output so shouldRespond is extracted + promptMocks.getStructuredPromptConfigMock.mockResolvedValue({ + chatSettings: { + maxResponses: null, + minMessagesBeforeResponding: 0, + canSelfTriggerCalls: true, + wordsPerMinute: 0, + }, + generationConfig: {}, + structuredOutputConfig: { + enabled: true, + type: 'JSON_SCHEMA', + appendToPrompt: true, + shouldRespondField: 'shouldRespond', + messageField: 'response', + explanationField: 'explanation', + readyToEndField: 'readyToEndChat', + schema: {type: 'OBJECT', properties: []}, + }, + }); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + createMockUser(), + ); + + expect(result).toBe(AgentMessageResult.DECLINED); + }); + + it("returns 'sent' when message is written to Firestore", async () => { + setupBasicMocks(); + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeModelResponse(), + ); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + createMockUser(), + ); + + expect(result).toBe(AgentMessageResult.SENT); + }); + + it("returns 'error' when model response fails", async () => { + setupBasicMocks(); + agentUtilsMocks.processModelResponseMock.mockResolvedValue({ + response: { + status: ModelResponseStatus.UNKNOWN_ERROR, + text: null, + reasoning: null, + rawResponse: null, + files: null, + }, + logId: 'log-1', + }); + + const result = await createAgentChatMessageFromPrompt( + 'exp-1', + 'cohort-1', + ['p-1'], + 'stage-1', + 'chat-1', + createMockUser(), + ); + + expect(result).toBe(AgentMessageResult.ERROR); + }); +}); + +describe('getAgentChatMessage shouldRespond/readyToEndChat decoupling', () => { + beforeEach(() => { + jest.clearAllMocks(); + firestoreMocks.getExperimenterDataFromExperimentMock.mockResolvedValue({ + apiKeys: {geminiKey: 'test-key'}, + }); + firestoreMocks.getFirestorePrivateChatMessagesMock.mockResolvedValue([ + {senderId: 'human-1', message: 'hello'}, // Need at least one message for readyToEndChat + ]); + }); + + function makeStructuredResponse( + shouldRespond: boolean, + readyToEndChat: boolean, + ) { + return makeModelResponse({ + text: JSON.stringify({ + shouldRespond, + response: 'test msg', + explanation: 'reason', + readyToEndChat, + }), + }); + } + + const stage = createMockStage(); + + const promptConfig = { + chatSettings: { + maxResponses: null, + minMessagesBeforeResponding: 0, + canSelfTriggerCalls: true, + wordsPerMinute: 0, + }, + generationConfig: {}, + structuredOutputConfig: { + enabled: true, + type: 'JSON_SCHEMA', + appendToPrompt: true, + shouldRespondField: 'shouldRespond', + messageField: 'response', + explanationField: 'explanation', + readyToEndField: 'readyToEndChat', + schema: {type: 'OBJECT', properties: []}, + }, + }; + + it('shouldRespond: false + readyToEndChat: false → does NOT set readyToEndChat on answer doc', async () => { + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeStructuredResponse(false, false), + ); + + const user = createMockUser({type: UserType.PARTICIPANT}); + const result = await getAgentChatMessage( + 'exp-1', + 'cohort-1', + ['p-1'], + stage, + user, + promptConfig, + ); + + expect(result.message).toBeNull(); + expect(result.success).toBe(true); + expect(firestoreMocks.participantAnswerDocSetMock).not.toHaveBeenCalled(); + }); + + it('shouldRespond: false + readyToEndChat: true → sets readyToEndChat on answer doc', async () => { + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeStructuredResponse(false, true), + ); + + const user = createMockUser({type: UserType.PARTICIPANT}); + const result = await getAgentChatMessage( + 'exp-1', + 'cohort-1', + ['p-1'], + stage, + user, + promptConfig, + ); + + expect(result.message).toBeNull(); + expect(result.success).toBe(true); + expect(firestoreMocks.participantAnswerDocSetMock).toHaveBeenCalledWith( + {readyToEndChat: true}, + {merge: true}, + ); + }); + + it('shouldRespond: true + readyToEndChat: false → returns message, does NOT set readyToEndChat', async () => { + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeStructuredResponse(true, false), + ); + + const user = createMockUser({type: UserType.PARTICIPANT}); + const result = await getAgentChatMessage( + 'exp-1', + 'cohort-1', + ['p-1'], + stage, + user, + promptConfig, + ); + + expect(result.message).not.toBeNull(); + expect(result.success).toBe(true); + expect(firestoreMocks.participantAnswerDocSetMock).not.toHaveBeenCalled(); + }); + + it('shouldRespond: true + readyToEndChat: true → sets readyToEndChat and returns message', async () => { + agentUtilsMocks.processModelResponseMock.mockResolvedValue( + makeStructuredResponse(true, true), + ); + + const user = createMockUser({type: UserType.PARTICIPANT}); + const result = await getAgentChatMessage( + 'exp-1', + 'cohort-1', + ['p-1'], + stage, + user, + promptConfig, + ); + + expect(result.message).not.toBeNull(); + expect(result.success).toBe(true); + expect(firestoreMocks.participantAnswerDocSetMock).toHaveBeenCalledWith( + {readyToEndChat: true}, + {merge: true}, + ); + }); +}); diff --git a/functions/src/chat/chat.agent.ts b/functions/src/chat/chat.agent.ts index db10591f5..6d13d2202 100644 --- a/functions/src/chat/chat.agent.ts +++ b/functions/src/chat/chat.agent.ts @@ -54,6 +54,13 @@ import {updateModelLogFiles} from '../log.utils'; // Functions for preparing, querying, and organizing agent chat responses. // **************************************************************************** +/** Result of an agent's attempt to send a chat message. */ +export enum AgentMessageResult { + SENT = 'sent', + DECLINED = 'declined', + ERROR = 'error', +} + /** Use persona chat prompt to create and send agent chat message. */ export async function createAgentChatMessageFromPrompt( experimentId: string, @@ -64,11 +71,11 @@ export async function createAgentChatMessageFromPrompt( // Profile of agent who will be sending the chat message user: ParticipantProfileExtended | MediatorProfileExtended, ) { - if (!user.agentConfig) return false; + if (!user.agentConfig) return AgentMessageResult.ERROR; // Stage (in order to determine stage kind) const stage = await getFirestoreStage(experimentId, stageId); - if (!stage) return false; + if (!stage) return AgentMessageResult.ERROR; // Fetches stored (else default) prompt config for given stage const promptConfig = (await getStructuredPromptConfig( @@ -78,7 +85,7 @@ export async function createAgentChatMessageFromPrompt( )) as ChatPromptConfig | undefined; if (!promptConfig) { - return false; + return AgentMessageResult.ERROR; } const isPrivateChat = stage.kind === StageKind.PRIVATE_CHAT; @@ -105,7 +112,7 @@ export async function createAgentChatMessageFromPrompt( const hasAlreadySent = (await triggerLogRef.get()).exists; if (hasAlreadySent) { - return false; // Already sent initial message + return AgentMessageResult.DECLINED; // Already sent initial message } // Mark that we're sending the initial message @@ -150,7 +157,9 @@ export async function createAgentChatMessageFromPrompt( ); message = response.message; if (!message) { - return response.success; + return response.success + ? AgentMessageResult.DECLINED + : AgentMessageResult.ERROR; } } @@ -161,7 +170,7 @@ export async function createAgentChatMessageFromPrompt( console.error( 'No participant ID provided for private chat message storage', ); - return false; + return AgentMessageResult.ERROR; } await sendAgentPrivateChatMessage( experimentId, @@ -182,7 +191,7 @@ export async function createAgentChatMessageFromPrompt( ); } - return true; + return AgentMessageResult.SENT; } /** Query for and return chat message for given agent and chat prompt configs. */ @@ -345,10 +354,6 @@ export async function getAgentChatMessage( return {message: null, success: false}; } - if (!shouldRespond) { - // Logic for not responding (handled below) - } - // Only if agent participant is ready to end chat if (readyToEndChat && user.type === UserType.PARTICIPANT) { // Ensure we don't end chat on the very first message @@ -368,19 +373,6 @@ export async function getAgentChatMessage( } if (!shouldRespond) { - // If agent decides not to respond in private chat, they are ready to end - if ( - stage.kind === StageKind.PRIVATE_CHAT && - user.type === UserType.PARTICIPANT && - chatMessages.length > 0 - ) { - const participantAnswerDoc = getFirestoreParticipantAnswerRef( - experimentId, - user.privateId, - stageId, - ); - await participantAnswerDoc.set({readyToEndChat: true}, {merge: true}); - } return {message: null, success: true}; } diff --git a/functions/src/triggers/chat.triggers.test.ts b/functions/src/triggers/chat.triggers.test.ts new file mode 100644 index 000000000..0652a254b --- /dev/null +++ b/functions/src/triggers/chat.triggers.test.ts @@ -0,0 +1,541 @@ +import firebaseFunctionsTest from 'firebase-functions-test'; +import {StageKind, UserType} from '@deliberation-lab/utils'; + +// All mocks are built inside factories to avoid Jest hoisting issues. +// Access them via __mocks__ re-exports after importing. + +jest.mock('../app', () => { + const getMock = jest.fn(); + const setMock = jest.fn().mockResolvedValue(undefined); + const transactionSetMock = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chain: Record = { + get: getMock, + set: setMock, + collection: jest.fn(), + doc: jest.fn(), + runTransaction: jest.fn(async (fn: (tx: {set: jest.Mock}) => unknown) => + fn({set: transactionSetMock}), + ), + }; + chain.collection.mockReturnValue(chain); + chain.doc.mockReturnValue(chain); + return { + __esModule: true, + app: { + firestore: jest.fn().mockReturnValue(chain), + }, + __mocks__: { + getMock, + setMock, + transactionSetMock, + }, + }; +}); + +jest.mock('../chat/chat.agent', () => { + const createAgentChatMessageFromPromptMock = jest.fn(); + const actual = jest.requireActual('../chat/chat.agent'); + return { + __esModule: true, + AgentMessageResult: actual.AgentMessageResult, + createAgentChatMessageFromPrompt: (...args: unknown[]) => + createAgentChatMessageFromPromptMock(...args), + __mocks__: {createAgentChatMessageFromPromptMock}, + }; +}); + +jest.mock('../chat/chat.utils', () => { + const sendErrorPrivateChatMessageMock = jest + .fn() + .mockResolvedValue(undefined); + return { + __esModule: true, + sendErrorPrivateChatMessage: (...args: unknown[]) => + sendErrorPrivateChatMessageMock(...args), + __mocks__: {sendErrorPrivateChatMessageMock}, + }; +}); + +jest.mock('../utils/firestore', () => { + const getFirestoreStageMock = jest.fn(); + const getFirestoreParticipantMock = jest.fn(); + const getFirestoreActiveMediatorsMock = jest.fn(); + const getFirestoreExperimentMock = jest.fn(); + const participantAnswerDocSetMock = jest.fn().mockResolvedValue(undefined); + const getFirestoreParticipantAnswerRefMock = jest.fn().mockReturnValue({ + set: participantAnswerDocSetMock, + }); + const getFirestoreParticipantRefMock = jest.fn().mockReturnValue({}); + return { + __esModule: true, + getFirestoreStage: (...args: unknown[]) => getFirestoreStageMock(...args), + getFirestoreParticipant: (...args: unknown[]) => + getFirestoreParticipantMock(...args), + getFirestoreActiveMediators: (...args: unknown[]) => + getFirestoreActiveMediatorsMock(...args), + getFirestoreActiveParticipants: jest.fn().mockResolvedValue([]), + getFirestoreExperiment: (...args: unknown[]) => + getFirestoreExperimentMock(...args), + getFirestoreParticipantAnswerRef: (...args: unknown[]) => + getFirestoreParticipantAnswerRefMock(...args), + getFirestoreParticipantRef: (...args: unknown[]) => + getFirestoreParticipantRefMock(...args), + getFirestoreStagePublicData: jest.fn().mockResolvedValue(null), + __mocks__: { + getFirestoreStageMock, + getFirestoreParticipantMock, + getFirestoreActiveMediatorsMock, + getFirestoreExperimentMock, + participantAnswerDocSetMock, + getFirestoreParticipantAnswerRefMock, + getFirestoreParticipantRefMock, + }, + }; +}); + +jest.mock('../participant.utils', () => { + const updateParticipantNextStageMock = jest.fn().mockResolvedValue({ + currentStageId: 'stage-2', + endExperiment: false, + }); + return { + __esModule: true, + updateParticipantNextStage: (...args: unknown[]) => + updateParticipantNextStageMock(...args), + __mocks__: {updateParticipantNextStageMock}, + }; +}); + +jest.mock('../stages/chat.time', () => ({ + __esModule: true, + startTimeElapsed: jest.fn(), +})); + +import {onPrivateChatMessageCreated} from './chat.triggers'; +import {AgentMessageResult} from '../chat/chat.agent'; +import {__mocks__ as appMocks} from '../app'; +import {__mocks__ as agentMocks} from '../chat/chat.agent'; +import {__mocks__ as chatUtilsMocks} from '../chat/chat.utils'; +import {__mocks__ as firestoreMocks} from '../utils/firestore'; +import {__mocks__ as participantUtilsMocks} from '../participant.utils'; + +const testEnv = firebaseFunctionsTest({projectId: 'deliberate-lab-test'}); + +// Helper to set up the message that the trigger reads from Firestore +function setupMessage(overrides: Record = {}) { + const message = { + type: UserType.PARTICIPANT, + isError: false, + discussionId: null, + senderId: 'sender-1', + ...overrides, + }; + + appMocks.getMock.mockResolvedValueOnce({ + data: () => message, + }); + + return message; +} + +const EVENT_PARAMS = { + experimentId: 'exp-1', + participantId: 'participant-1', + stageId: 'stage-1', + chatId: 'chat-1', +}; + +function createMockParticipant(overrides: Record = {}) { + return { + agentConfig: {agentId: 'agent-1'}, + currentStageId: 'stage-1', + currentCohortId: 'cohort-1', + privateId: 'participant-1', + publicId: 'pub-1', + ...overrides, + }; +} + +function createMockMediator(id: string) { + return { + type: UserType.MEDIATOR, + publicId: `mediator-${id}`, + agentConfig: {agentId: `agent-mediator-${id}`}, + }; +} + +describe('onPrivateChatMessageCreated', () => { + let wrapped: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + wrapped = testEnv.wrap(onPrivateChatMessageCreated); + + // Default stage + firestoreMocks.getFirestoreStageMock.mockResolvedValue({ + id: 'stage-1', + kind: StageKind.PRIVATE_CHAT, + }); + }); + + it('skips error messages', async () => { + setupMessage({isError: true}); + + await wrapped({params: EVENT_PARAMS}); + + expect(firestoreMocks.getFirestoreStageMock).not.toHaveBeenCalled(); + expect( + agentMocks.createAgentChatMessageFromPromptMock, + ).not.toHaveBeenCalled(); + }); + + describe('mediator error handling', () => { + it('sends error message when mediator returns error', async () => { + setupMessage(); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.ERROR, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect( + chatUtilsMocks.sendErrorPrivateChatMessageMock, + ).toHaveBeenCalledWith( + 'exp-1', + 'participant-1', + 'stage-1', + expect.objectContaining({message: 'Error fetching response'}), + ); + }); + + it('does not send error message when mediator returns declined', async () => { + setupMessage(); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect( + chatUtilsMocks.sendErrorPrivateChatMessageMock, + ).not.toHaveBeenCalled(); + }); + }); + + describe('no mediators configured', () => { + it('sends error for human participants', async () => { + setupMessage(); + const participant = createMockParticipant({agentConfig: null}); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([]); + + await wrapped({params: EVENT_PARAMS}); + + expect( + chatUtilsMocks.sendErrorPrivateChatMessageMock, + ).toHaveBeenCalledWith( + 'exp-1', + 'participant-1', + 'stage-1', + expect.objectContaining({message: 'No mediators found'}), + ); + }); + + it('skips error and advances agent participants', async () => { + setupMessage(); + const participant = createMockParticipant(); + const experiment = {stageIds: ['stage-1', 'stage-2']}; + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([]); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(experiment); + + await wrapped({params: EVENT_PARAMS}); + + expect( + chatUtilsMocks.sendErrorPrivateChatMessageMock, + ).not.toHaveBeenCalled(); + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).toHaveBeenCalled(); + }); + }); + + describe('agent advancement', () => { + it('advances when all mediators declined and trigger is from agent message', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + const experiment = {stageIds: ['stage-1', 'stage-2']}; + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(experiment); + + await wrapped({params: EVENT_PARAMS}); + + // readyToEndChat set + expect( + firestoreMocks.getFirestoreParticipantAnswerRefMock, + ).toHaveBeenCalledWith('exp-1', 'participant-1', 'stage-1'); + expect(firestoreMocks.participantAnswerDocSetMock).toHaveBeenCalledWith( + {readyToEndChat: true}, + {merge: true}, + ); + // Advanced + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).toHaveBeenCalledWith('exp-1', participant, experiment.stageIds); + }); + + it('advances when all mediators declined and agent also declined on mediator message', async () => { + setupMessage({type: UserType.MEDIATOR}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + const experiment = {stageIds: ['stage-1', 'stage-2']}; + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + // First call: mediator → declined. Second call: agent → declined. + agentMocks.createAgentChatMessageFromPromptMock + .mockResolvedValueOnce(AgentMessageResult.DECLINED) + .mockResolvedValueOnce(AgentMessageResult.DECLINED); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(experiment); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).toHaveBeenCalled(); + }); + + it('does NOT advance when mediator message triggers and agent sends', async () => { + setupMessage({type: UserType.MEDIATOR}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + // Mediator declines (canSelfTriggerCalls), agent sends + agentMocks.createAgentChatMessageFromPromptMock + .mockResolvedValueOnce(AgentMessageResult.DECLINED) + .mockResolvedValueOnce(AgentMessageResult.SENT); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('does NOT advance for human participants', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant({agentConfig: null}); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('does NOT advance when mediator results are mixed', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + createMockMediator('1'), + createMockMediator('2'), + ]); + // Mediator 1 declined, mediator 2 sent + agentMocks.createAgentChatMessageFromPromptMock + .mockResolvedValueOnce(AgentMessageResult.DECLINED) + .mockResolvedValueOnce(AgentMessageResult.SENT); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('does NOT advance when all mediators return error', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.ERROR, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('does NOT advance when agent is already on a different stage', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant({currentStageId: 'stage-2'}); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('does NOT advance or set readyToEndChat when experiment is not found', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(null); + + await wrapped({params: EVENT_PARAMS}); + + expect(firestoreMocks.participantAnswerDocSetMock).not.toHaveBeenCalled(); + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('advances when multiple mediators all declined and trigger is from agent message', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + const experiment = {stageIds: ['stage-1', 'stage-2']}; + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + createMockMediator('1'), + createMockMediator('2'), + createMockMediator('3'), + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(experiment); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).toHaveBeenCalled(); + }); + + it('does NOT advance when multiple mediators all declined but agent sends on mediator message', async () => { + setupMessage({type: UserType.MEDIATOR}); + const participant = createMockParticipant(); + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + createMockMediator('1'), + createMockMediator('2'), + ]); + // All mediators decline, then agent sends + agentMocks.createAgentChatMessageFromPromptMock + .mockResolvedValueOnce(AgentMessageResult.DECLINED) + .mockResolvedValueOnce(AgentMessageResult.DECLINED) + .mockResolvedValueOnce(AgentMessageResult.SENT); + + await wrapped({params: EVENT_PARAMS}); + + expect( + participantUtilsMocks.updateParticipantNextStageMock, + ).not.toHaveBeenCalled(); + }); + + it('sets readyToEndChat on answer doc before advancing', async () => { + setupMessage({type: UserType.PARTICIPANT}); + const participant = createMockParticipant(); + const mediator = createMockMediator('1'); + const experiment = {stageIds: ['stage-1', 'stage-2']}; + + firestoreMocks.getFirestoreParticipantMock.mockResolvedValue(participant); + firestoreMocks.getFirestoreActiveMediatorsMock.mockResolvedValue([ + mediator, + ]); + agentMocks.createAgentChatMessageFromPromptMock.mockResolvedValue( + AgentMessageResult.DECLINED, + ); + firestoreMocks.getFirestoreExperimentMock.mockResolvedValue(experiment); + + // Track call order + const callOrder: string[] = []; + firestoreMocks.participantAnswerDocSetMock.mockImplementation(() => { + callOrder.push('readyToEndChat'); + return Promise.resolve(); + }); + participantUtilsMocks.updateParticipantNextStageMock.mockImplementation( + () => { + callOrder.push('advanceStage'); + return Promise.resolve({ + currentStageId: 'stage-2', + endExperiment: false, + }); + }, + ); + + await wrapped({params: EVENT_PARAMS}); + + expect(callOrder[0]).toBe('readyToEndChat'); + expect(callOrder[1]).toBe('advanceStage'); + }); + }); +}); diff --git a/functions/src/triggers/chat.triggers.ts b/functions/src/triggers/chat.triggers.ts index 7fc1925b2..98cfb0443 100644 --- a/functions/src/triggers/chat.triggers.ts +++ b/functions/src/triggers/chat.triggers.ts @@ -9,12 +9,19 @@ import { import { getFirestoreActiveMediators, getFirestoreActiveParticipants, + getFirestoreExperiment, getFirestoreParticipant, + getFirestoreParticipantAnswerRef, + getFirestoreParticipantRef, getFirestoreStage, getFirestoreStagePublicData, } from '../utils/firestore'; -import {createAgentChatMessageFromPrompt} from '../chat/chat.agent'; +import { + createAgentChatMessageFromPrompt, + AgentMessageResult, +} from '../chat/chat.agent'; import {sendErrorPrivateChatMessage} from '../chat/chat.utils'; +import {updateParticipantNextStage} from '../participant.utils'; import {startTimeElapsed} from '../stages/chat.time'; import {app} from '../app'; @@ -158,7 +165,8 @@ export const onPrivateChatMessageCreated = onDocumentCreated( true, ); - await Promise.all( + // Send agent mediator messages and collect results + const results = await Promise.all( mediators.map(async (mediator) => { const result = await createAgentChatMessageFromPrompt( event.params.experimentId, @@ -169,7 +177,7 @@ export const onPrivateChatMessageCreated = onDocumentCreated( mediator, ); - if (!result) { + if (result === AgentMessageResult.ERROR) { await sendErrorPrivateChatMessage( event.params.experimentId, participant.privateId, @@ -184,12 +192,14 @@ export const onPrivateChatMessageCreated = onDocumentCreated( }, ); } + + return result; }), ); - // If no mediator, return error (otherwise participant may wait - // indefinitely for a response). - if (mediators.length === 0) { + // Only send "No mediators found" error for human participants. + // Agent participants will be advanced by the allMediatorsDone check below. + if (mediators.length === 0 && !participant.agentConfig) { await sendErrorPrivateChatMessage( event.params.experimentId, participant.privateId, @@ -201,11 +211,16 @@ export const onPrivateChatMessageCreated = onDocumentCreated( ); } + const allMediatorsDone = + mediators.length === 0 || + results.every((r) => r === AgentMessageResult.DECLINED); + // Send agent participant messages (if participant is an agent) + let agentResult: AgentMessageResult | null = null; if (participant.agentConfig) { // Ensure agent only responds to mediator, not themselves if (message.type === UserType.MEDIATOR) { - await createAgentChatMessageFromPrompt( + agentResult = await createAgentChatMessageFromPrompt( event.params.experimentId, participant.currentCohortId, [participant.privateId], // Pass agent's own ID as array @@ -215,5 +230,57 @@ export const onPrivateChatMessageCreated = onDocumentCreated( ); } } + + // Advance agent participant if the conversation is dead: + // - All mediators declined AND the triggering message is not from a mediator + // (a mediator "declining" to respond to its own message is not the same + // as being permanently done — it just means canSelfTriggerCalls is false) + // - OR all mediators declined AND the agent also declined (both sides done) + // + // Guard on currentStageId to prevent double-advancement if the trigger + // fires twice (Cloud Functions at-least-once delivery). + // Use updateParticipantNextStage directly because private chat stages + // typically don't have publicStageData (which updateParticipantReadyToEndChat + // requires). + const shouldAdvanceAgent = + allMediatorsDone && + participant.agentConfig && + participant.currentStageId === event.params.stageId && + (message.type !== UserType.MEDIATOR || + agentResult === AgentMessageResult.DECLINED); + + if (shouldAdvanceAgent) { + // Advance to next stage + const experiment = await getFirestoreExperiment( + event.params.experimentId, + ); + if (experiment) { + // Set readyToEndChat first for data consistency with the normal + // end-of-chat flow. If advancement fails after this point, the state + // matches a human participant who clicked "end chat" but hasn't + // been advanced yet — a recoverable state. + const participantAnswerDoc = getFirestoreParticipantAnswerRef( + event.params.experimentId, + participant.privateId, + event.params.stageId, + ); + await participantAnswerDoc.set({readyToEndChat: true}, {merge: true}); + + await updateParticipantNextStage( + event.params.experimentId, + participant, + experiment.stageIds, + ); + // updateParticipantNextStage mutates the participant in place but does + // not write to Firestore — the caller must write. + const participantDoc = getFirestoreParticipantRef( + event.params.experimentId, + participant.privateId, + ); + await app.firestore().runTransaction(async (transaction) => { + transaction.set(participantDoc, participant); + }); + } + } }, );