/opt/mawid/apps/api/src/agent
NameSizeModeActions
agent.e2e.spec.ts132960644editdlrm
agent.module.ts8320644editdlrm
agent.service.ts81630644editdlrm
availability.spec.ts84820644editdlrm
availability.ts51830644editdlrm
booking-tools.service.ts144400644editdlrm
openai.client.ts22470644editdlrm
system-prompt.ts18550644editdlrm
tool-definitions.ts36140644editdlrm
tz.ts28820644editdlrm
Edit: /opt/mawid/apps/api/src/agent/agent.e2e.spec.ts (13296B)
/** * E2E agent tests with a scripted (mocked) Claude — PROJECT_PLAN Phase 3 task 4: * happy booking path, reschedule, cancel, waitlist, handoff (+ rate limit). * Requires the docker-compose postgres on localhost:5432. */ import type OpenAI from 'openai'; import { PrismaService } from '../prisma/prisma.service'; import type { ReminderService } from '../reminders/reminder.service'; import type { WaitlistService } from '../waitlist/waitlist.service'; import { AgentService } from './agent.service'; import { BookingToolsService } from './booking-tools.service'; import type { AgentLlmClient } from './openai.client'; import { wallTimeToUtc } from './tz'; type Completion = OpenAI.Chat.Completions.ChatCompletion; type CreateParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming; process.env.DATABASE_URL ??= 'postgresql://mawid:mawid@localhost:5432/mawid'; const TZ = 'Europe/Istanbul'; const ALL_WEEK = Object.fromEntries( ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((d) => [d, [{ start: '09:00', end: '17:00' }]]), ); function textResponse(text: string): Completion { return { choices: [{ message: { role: 'assistant', content: text }, finish_reason: 'stop' }], } as Completion; } function toolResponse(name: string, input: object, text?: string): Completion { return { choices: [ { message: { role: 'assistant', content: text ?? null, tool_calls: [ { id: `call_${name}_${Math.random().toString(36).slice(2, 8)}`, type: 'function', function: { name, arguments: JSON.stringify(input) }, }, ], }, finish_reason: 'tool_calls', }, ], } as Completion; } class ScriptedLlm implements AgentLlmClient { calls: CreateParams[] = []; private script: Completion[] = []; enqueue(...responses: Completion[]) { this.script.push(...responses); } chat = { completions: { create: async (params: CreateParams): Promise => { // Snapshot: the agent mutates its messages array between iterations. this.calls.push(JSON.parse(JSON.stringify(params))); const next = this.script.shift(); if (!next) throw new Error('ScriptedLlm: no scripted response left'); return next; }, }, }; } describe('AgentService E2E (mocked Claude)', () => { const prisma = new PrismaService(); let clinicId: string; let staffId: string; let serviceId: string; // Tomorrow 10:00 and 11:00 clinic-local — always inside the all-week hours. const tomorrow = new Date(Date.now() + 24 * 3600_000); const tomorrowDate = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`; const t10 = (() => { const d = new Date(tomorrow); return wallTimeToUtc(TZ, d.getFullYear(), d.getMonth() + 1, d.getDate(), 10, 0); })(); const t11 = (() => { const d = new Date(tomorrow); return wallTimeToUtc(TZ, d.getFullYear(), d.getMonth() + 1, d.getDate(), 11, 0); })(); beforeAll(async () => { const clinic = await prisma.clinic.create({ data: { name: `agent-e2e-${Date.now()}`, phone: '+900000000099', timezone: TZ, defaultLanguage: 'tr', workingHours: ALL_WEEK, }, }); clinicId = clinic.id; const service = await prisma.service.create({ data: { clinicId, name: { ar: 'فحص', tr: 'Muayene', en: 'Checkup' }, durationMinutes: 30, }, }); serviceId = service.id; const staff = await prisma.staff.create({ data: { clinicId, name: 'Dr. E2E', workingHours: ALL_WEEK, services: { connect: [{ id: serviceId }] }, }, }); staffId = staff.id; }); afterAll(async () => { await prisma.appointment.deleteMany({ where: { clinicId } }); await prisma.clinic.delete({ where: { id: clinicId } }); await prisma.$disconnect(); }); async function makeConversation(suffix: string) { const patient = await prisma.patient.create({ data: { clinicId, waPhone: `+90599${Date.now().toString().slice(-7)}${suffix}`, name: `Patient ${suffix}`, language: 'tr', }, }); const conversation = await prisma.conversation.create({ data: { clinicId, patientId: patient.id }, }); return { patient, conversation }; } function makeAgent() { const llm = new ScriptedLlm(); const reminders = { scheduleForAppointment: async () => {}, cancelForAppointment: async () => {}, } as unknown as ReminderService; const waitlist = { onAppointmentCancelled: async () => {} } as unknown as WaitlistService; const agent = new AgentService( prisma, new BookingToolsService(prisma, reminders, waitlist), llm, ); return { llm, agent }; } it('happy path: availability is checked, then the appointment is created as pending', async () => { const { patient, conversation } = await makeConversation('a'); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('booking'), // intent gate // Pin the range: without fromDate the result is capped at 24 slots from // "now", which only reaches tomorrow when run late in the day. toolResponse('get_availability', { serviceId, fromDate: tomorrowDate }), toolResponse('create_appointment', { serviceId, staffId, startsAtUtc: t10.toISOString(), }), textResponse('Randevunuz oluşturuldu, yarın 10:00’da bekleriz!'), ); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Yarın muayene için randevu almak istiyorum', }); expect(reply).toContain('Randevunuz oluşturuldu'); const appointment = await prisma.appointment.findFirst({ where: { clinicId, patientId: patient.id }, }); expect(appointment?.status).toBe('pending'); expect(appointment?.source).toBe('whatsapp'); expect(appointment?.startsAt.toISOString()).toBe(t10.toISOString()); // get_availability really returned the booked slot (never invent availability) const availabilityResult = JSON.parse(llm.calls[2].messages.at(-1)!.content as string); expect(availabilityResult.ok).toBe(true); expect( availabilityResult.slots.some((s: { startsAtUtc: string }) => s.startsAtUtc === t10.toISOString()), ).toBe(true); // guardrail: every tool call audited const audits = await prisma.auditLog.findMany({ where: { clinicId, actor: 'agent' } }); expect(audits.map((a) => a.action)).toEqual( expect.arrayContaining(['tool:get_availability', 'tool:create_appointment']), ); }); it('double-booking the same slot is rejected', async () => { const { patient, conversation } = await makeConversation('b'); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('booking'), toolResponse('create_appointment', { serviceId, staffId, startsAtUtc: t10.toISOString() }), textResponse('Maalesef bu saat az önce doldu.'), ); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Yarın 10:00 uygun mu?', }); expect(reply).toContain('doldu'); const toolResult = JSON.parse(llm.calls[2].messages.at(-1)!.content as string); expect(toolResult.ok).toBe(false); const count = await prisma.appointment.count({ where: { clinicId, startsAt: t10, status: 'pending' }, }); expect(count).toBe(1); // only the appointment from the happy-path test }); it('reschedule moves the appointment to the new slot', async () => { const { patient, conversation } = await makeConversation('c'); const appointment = await prisma.appointment.create({ data: { clinicId, patientId: patient.id, staffId, serviceId, startsAt: t11, endsAt: new Date(t11.getTime() + 30 * 60_000), status: 'confirmed', source: 'whatsapp', }, }); const t14 = new Date(t11.getTime() + 3 * 3600_000); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('booking'), toolResponse('get_patient_appointments', {}), toolResponse('reschedule_appointment', { appointmentId: appointment.id, newStartsAtUtc: t14.toISOString(), }), textResponse('Randevunuz 14:00’a alındı.'), ); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Randevumu öğleden sonraya alabilir miyiz?', }); expect(reply).toContain('14:00'); const updated = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(updated?.startsAt.toISOString()).toBe(t14.toISOString()); expect(updated?.status).toBe('pending'); // re-confirmation required after move }); it('cancel sets status and keeps the reason', async () => { const { patient, conversation } = await makeConversation('d'); const appointment = await prisma.appointment.create({ data: { clinicId, patientId: patient.id, staffId, serviceId, startsAt: new Date(t10.getTime() + 5 * 3600_000), endsAt: new Date(t10.getTime() + 5.5 * 3600_000), status: 'confirmed', source: 'whatsapp', }, }); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('booking'), toolResponse('cancel_appointment', { appointmentId: appointment.id, reason: 'seyahat' }), textResponse('Randevunuz iptal edildi, geçmiş olsun.'), ); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Yarınki randevumu iptal etmem gerekiyor', }); expect(reply).toContain('iptal'); const cancelled = await prisma.appointment.findUnique({ where: { id: appointment.id } }); expect(cancelled?.status).toBe('cancelled'); expect(cancelled?.cancelReason).toBe('seyahat'); }); it('waitlist entry is created when no slot fits', async () => { const { patient, conversation } = await makeConversation('e'); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('booking'), toolResponse('add_to_waitlist', { serviceId, preferredWindow: { note: 'sadece cumartesi öğleden sonra' }, }), textResponse('Sizi bekleme listesine ekledim.'), ); await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Sadece cumartesi öğleden sonra gelebilirim', }); const entry = await prisma.waitlistEntry.findFirst({ where: { clinicId, patientId: patient.id }, }); expect(entry?.status).toBe('active'); expect((entry?.preferredWindow as { note: string }).note).toBe('sadece cumartesi öğleden sonra'); }); it('handoff flags the conversation and silences the agent afterwards', async () => { const { patient, conversation } = await makeConversation('f'); const { llm, agent } = makeAgent(); llm.enqueue( textResponse('other'), toolResponse( 'handoff_to_human', { reason: 'patient is upset about billing' }, 'Anlıyorum, bir yetkili kısa süre içinde sizinle ilgilenecek.', ), ); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'Bu klinikten şikayetçiyim! Fatura yanlış!', }); expect(reply).toContain('yetkili'); const updated = await prisma.conversation.findUnique({ where: { id: conversation.id } }); expect((updated?.state as { handedOff: boolean }).handedOff).toBe(true); // agent stays silent once handed off — no LLM call happens const callsBefore = llm.calls.length; const second = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'hala bekliyorum', }); expect(second).toBeNull(); expect(llm.calls.length).toBe(callsBefore); }); it('rate limit: 16th turn within an hour hands off with the i18n notice', async () => { const { patient, conversation } = await makeConversation('g'); const now = Date.now(); await prisma.conversation.update({ where: { id: conversation.id }, data: { state: { agentTurnsMs: Array.from({ length: 15 }, () => now - 60_000) } }, }); const { llm, agent } = makeAgent(); const reply = await agent.handleInbound({ clinicId, patientId: patient.id, conversationId: conversation.id, text: 'tekrar deneyelim', }); expect(reply).toBe( 'Klinik ekibimizden biri kısa süre içinde sizinle iletişime geçecek, sabrınız için teşekkürler 🙏', ); expect(llm.calls.length).toBe(0); const updated = await prisma.conversation.findUnique({ where: { id: conversation.id } }); expect((updated?.state as { handedOff: boolean }).handedOff).toBe(true); }); });