diff --git a/README.md b/README.md index a7dd60f..59ca6d3 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,15 @@ The dev command builds packages, starts the API, Vite and TypeScript watchers. S 6. Explicitly agree to 15:30. `accept_slot` goes through the manager, saves Jordan's replacement booking, and then waives Chris's fee. Within one second, the dashboard shows **Jordan at 3:30 PM**, **$45 recovered**, **$15 waived**, and the ordered activity log. Connecting or ending a call does not book anything. 7. The agent says goodbye and ends the call after a successful booking or decline. Reset to repeat. Alternatively, decline or end without accepting: the fee stays pending and the manager prepares Sam, then Taylor. Wait for the current audio call to finish before answering the next one. An exhausted waitlist leaves the fee pending. For an older ElevenLabs agent, run `npm run voice:update` once to enable automatic hangup. -No credentials are required to use the calendar, reset, or run automated tests. A real microphone conversation requires ElevenLabs credentials. For an explicitly simulated, audio-free backend demo, `POST /api/refills/:runId/simulate` accepts `{ "type": "accepted", "startsAt": "2026-09-19T15:30:00-07:00" }` or a declined/no_answer outcome; it is labeled simulated in the log and cannot run during an open web call. +No credentials are required to use the calendar, reset, or run automated tests. A real microphone conversation requires ElevenLabs credentials. For an explicitly simulated, audio-free backend demo, `POST /api/refills/:runId/simulate` accepts `{ "type": "accepted", "startsAt": "" }` or a declined/no_answer outcome; it is labeled simulated in the log and cannot run during an open web call. ## Data and reset -The seed is a fixed demo day, **September 19, 2026**, America/Los_Angeles, one barber, five 45-minute $45 bookings and three waitlist clients. Original bookings have a $15 simulated cancellation fee. Alternative starts are the cancelled start or 30 minutes later, subject to business hours, service duration and calendar collisions. +The seed uses **tomorrow in America/Los_Angeles**, calculated when the API starts with that date’s UTC offset, one barber, five 45-minute $45 bookings and three waitlist clients. Original bookings have a $15 simulated cancellation fee. Alternative starts are the cancelled start or 30 minutes later, subject to business hours, service duration and calendar collisions. SQLite is created automatically at **`.demo/dispatch.sqlite` in the repo root**, independent of the API working directory. It is ignored by Git. Set `DEMO_DB_PATH` to an absolute path to override it (`:memory:` for tests). No database installation or migrations command is needed. -**Reset demo** calls `POST /api/demo/reset`, reseeds the calendar/clients, clears manager runs and logs, and invalidates old voice sessions. Any open old voice page closes its audio on the next session check. Bookings, waitlist status, fees and activity survive API restarts. Manager runs and browser sessions are in memory: after a restart, an unfinished cancellation starts a fresh offer from the remaining waitlist. Previous declines are not persisted as exclusions. Reset before a presentation for a predictable starting state. +**Reset demo** calls `POST /api/demo/reset`, reseeds the calendar/clients, clears manager runs and logs, and invalidates old voice sessions. Any open old voice page closes its audio on the next session check. Bookings, waitlist status, fees and activity survive API restarts on the same demo day. Starting the API on a new day automatically reseeds the stored calendar for tomorrow. Restart the API before a demo if it has been running overnight. Manager runs and browser sessions are in memory: after a restart, an unfinished cancellation starts a fresh offer from the remaining waitlist. Previous declines are not persisted as exclusions. Reset before a presentation for a predictable starting state. Brand colors and export formats live in [docs/design](docs/design/README.md). diff --git a/apps/api/test/time.test.ts b/apps/api/test/time.test.ts index cf49cea..2f13d11 100644 --- a/apps/api/test/time.test.ts +++ b/apps/api/test/time.test.ts @@ -1,6 +1,8 @@ +import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import { spokenDate } from '@dispatch/voice'; +import { DEMO_DATE, demoDate, demoTime } from '@dispatch/data'; const zone = 'America/Los_Angeles'; @@ -24,3 +26,33 @@ test('tomorrow follows the local calendar across daylight-saving and year bounda assert.equal(spokenDate('2027-01-01', zone, new Date('2026-12-31T23:30:00-08:00')), 'tomorrow'); }); + +test('the demo day is tomorrow in the shop timezone, so offered times are never past', () => { + // Late evening in Los Angeles: a "today" demo would already be offering past times. + assert.equal(demoDate(new Date('2026-09-20T04:30:00Z')), '2026-09-20'); + assert.equal(demoDate(new Date('2026-09-19T17:00:00Z')), '2026-09-20'); + // Across a DST boundary the local calendar still advances exactly one day. + assert.equal(demoDate(new Date('2026-03-07T23:30:00-08:00')), '2026-03-08'); + assert.equal(demoDate(new Date('2026-12-31T23:30:00-08:00')), '2027-01-01'); + // Every seeded opening is in the future, with the offset that date really has. + assert.ok(Date.parse(demoTime('09:00')) > Date.now(), 'first slot is still ahead'); + assert.ok(demoTime('15:00').startsWith(`${DEMO_DATE}T15:00:00`), 'slots sit on the demo date'); + assert.match(demoTime('15:00'), /[+-]\d{2}:\d{2}$/); +}); + +test('seeded wall-clock times use the correct offset in winter and on DST transition days', () => { + for (const [now, expected] of [ + ['2026-01-10T20:00:00Z', '2026-01-11T15:00:00-08:00'], + ['2026-03-07T20:00:00Z', '2026-03-08T15:00:00-07:00'], + ['2026-10-31T20:00:00Z', '2026-11-01T15:00:00-08:00'], + ]) { + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { mock } from 'node:test'; + mock.timers.enable({ apis: ['Date'], now: Date.parse(${JSON.stringify(now)}) }); + const { demoTime, localTime } = await import('@dispatch/data'); + console.log(JSON.stringify([demoTime('15:00'), localTime(demoTime('15:00'))])); + `], { encoding: 'utf8' }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout), [expected, '15:00']); + } +}); diff --git a/apps/api/test/voice.test.ts b/apps/api/test/voice.test.ts index 13200ae..155cedd 100644 --- a/apps/api/test/voice.test.ts +++ b/apps/api/test/voice.test.ts @@ -1,9 +1,11 @@ import 'reflect-metadata'; +import { DatabaseSync } from 'node:sqlite'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import { NestFactory } from '@nestjs/core'; import { VoiceModule } from '../dist/voice/voice.module.js'; -import { SqliteBookings, demoTime } from '@dispatch/data'; +import { spokenDate } from '@dispatch/voice'; +import { DEMO_DATE, SqliteBookings, demoTime } from '@dispatch/data'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -54,8 +56,8 @@ test('shared HTTP demo: cancellation → offer → voice tool → calendar booki assert.equal(started.dynamicVariables.customer_name, 'Jordan Davis'); assert.equal(started.dynamicVariables.offered_time, '3 PM'); assert.equal(started.dynamicVariables.available_times, '3 PM, 3:30 PM'); - assert.match(started.dynamicVariables.date, /^(today|tomorrow|Saturday, September 19th)$/); - assert.equal(started.session.context.date, '2026-09-19', 'booking context keeps its ISO date'); + assert.equal(started.dynamicVariables.date, spokenDate(DEMO_DATE, 'America/Los_Angeles')); + assert.equal(started.session.context.date, DEMO_DATE, 'booking context keeps its ISO date'); assert.match(started.session.managerBrief.disclosure, /Jordan Davis/); assert.match(started.session.managerBrief.offer, /45/); assert.equal((await post('voice/sessions')).status, 409, 'second tab cannot claim the same offer'); @@ -148,3 +150,30 @@ test('SQLite persists bookings, clients and fees across reopen; reset restores s assert.equal(store.waitlist()[0]?.status, 'waiting'); } finally { store.close(); rmSync(directory, { recursive: true, force: true }); } }); + + +test('opening an older demo database reseeds the calendar, waitlist and activity for tomorrow', async () => { + const directory = mkdtempSync(join(tmpdir(), 'dispatch-stale-day-')); + const path = join(directory, 'demo.sqlite'); + let store = new SqliteBookings(path); + try { + await store.cancelBooking('slot_3pm'); + await store.bookReplacement({ slotId: 'slot_3pm', contactId: 'client_5', startsAt: demoTime('15:30'), idempotencyKey: 'old-day' }); + await store.waiveCancellationFee('slot_3pm'); + const oldBookings = store.bookings(); + store.close(); + const db = new DatabaseSync(path); + try { + for (const booking of oldBookings) { + booking.startsAt = `2000-01-01${booking.startsAt.slice(10)}`; + db.prepare('UPDATE bookings SET data = ? WHERE id = ?').run(JSON.stringify(booking), booking.id); + } + } finally { db.close(); } + store = new SqliteBookings(path); + assert.equal(store.bookings().length, 5); + assert.ok(store.bookings().every(booking => booking.startsAt.startsWith(DEMO_DATE) && booking.status === 'booked' && booking.feeStatus === 'not_due')); + assert.ok(store.waitlist().every(client => client.status === 'waiting')); + assert.equal(store.events().length, 1); + assert.match(store.events()[0]!.message, /Demo reset/); + } finally { store.close(); rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/apps/web/src/voice/VoiceDemo.tsx b/apps/web/src/voice/VoiceDemo.tsx index 05ec12f..1102bf2 100644 --- a/apps/web/src/voice/VoiceDemo.tsx +++ b/apps/web/src/voice/VoiceDemo.tsx @@ -7,6 +7,9 @@ import { CalendarDays, MessagesSquare } from 'lucide-react'; import './voice.css'; import { Signal } from '../MotionUI'; +// Long enough for a one-line goodbye, short enough that the demo never stalls. +const GOODBYE_MS = 8000; + async function api(path: string, body?: unknown): Promise { const response = await fetch(`/api/voice/${path}`, { method: body === undefined ? 'GET' : 'POST', @@ -32,6 +35,7 @@ export function VoiceDemo() { const connection = useRef(null); const current = useRef<{ id?: string; cancelled: boolean } | null>(null); const busy = useRef(false); + const hangup = useRef | null>(null); const incomingOffer = status === 'disconnected' && config?.configured && !config.callActive && config.attemptId !== answeredOffer ? config?.attemptId ?? null : null; const ringtone = useRingtone(incomingOffer); @@ -51,6 +55,7 @@ export function VoiceDemo() { const timer = setInterval(refresh, 1000); return () => { mounted = false; clearInterval(timer); + if (hangup.current) clearTimeout(hangup.current); if (current.current) current.current.cancelled = true; void connection.current?.endSession(); if (current.current?.id) void api(`sessions/${current.current.id}/end`, { reason: 'ended' }).catch(() => {}); @@ -109,6 +114,13 @@ export function VoiceDemo() { if (!isCurrent()) return JSON.stringify({ ok: false, message: 'The call ended.' }); const result = await api>(`sessions/${attempt.id}/${action}`, parameters); if ('status' in result && isCurrent()) setSession(result as VoiceDemoSession); + // The agent should say goodbye and call end_call itself. This is the backstop so a + // booked or declined call always hangs up, even if it keeps talking instead. + if ((result.status === 'accepted' || result.status === 'declined') && isCurrent() && !hangup.current) { + hangup.current = setTimeout(() => { + if (isCurrent() && !attempt.closed) void end(); + }, GOODBYE_MS); + } return JSON.stringify({ ok: true, ...result }); } catch (err) { const message = err instanceof Error ? err.message : 'Could not complete the request'; @@ -136,6 +148,7 @@ export function VoiceDemo() { if (!isCurrent()) return; attempt.closed = true; connection.current = null; + if (hangup.current) { clearTimeout(hangup.current); hangup.current = null; } setStatus('disconnecting'); void api(`sessions/${attempt.id}/end`, { reason: details.reason === 'error' ? 'failed' : 'ended' }) .then((value) => { if (isCurrent()) setSession(value); }) diff --git a/docs/voice.md b/docs/voice.md index 092229f..6cf9dfc 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -32,7 +32,7 @@ On `/voice`, click **Enable ringtone** once before the demo. Browsers require a - “No thanks.” → `decline_slot` → goodbye → automatic `end_call`; fee remains pending and the next candidate can answer a new web call. - End a call without accepting → no booking. Ending after an acceptance preserves the booking. -The demo calendar is September 19, 2026, America/Los_Angeles, at Apblendzz. The manager selects the customer from SQLite's waitlist. For the 15:00 cancellation, 15:30 is available; 16:00 is unavailable because the 45-minute service would overlap a 16:30 appointment. Every call uses the same calendar as the shop dashboard. See [the exact flow and reset behavior](../README.md#exact-demo-flow). +The demo calendar is tomorrow in America/Los_Angeles at Apblendzz, calculated when the API starts. A new demo day automatically reseeds an older database. Restart the API before a demo if it has been running overnight. The manager selects the customer from SQLite's waitlist. For the 15:00 cancellation, 15:30 is available; 16:00 is unavailable because the 45-minute service would overlap a 16:30 appointment. Every call uses the same calendar as the shop dashboard. See [the exact flow and reset behavior](../README.md#exact-demo-flow). The manager's `CallRequest` supplies customer/shop/slot variables and the call brief. The browser forwards that brief as a contextual update using the existing SDK; WebRTC transport and the configured ElevenLabs agent stay unchanged. Booking tools return to the manager and shared repository. A second browser cannot start a concurrent call, and ending a call without acceptance advances the waitlist without creating a booking. diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 2a46423..049ff57 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -15,4 +15,4 @@ export interface BookingRepository { }): Promise<{ bookingId: string }>; waiveCancellationFee(slotId: string): Promise; } -export { SqliteBookings, DEMO_DATE, demoTime, localTime } from './sqlite.js'; +export { SqliteBookings, DEMO_DATE, TIMEZONE, demoDate, demoTime, localTime } from './sqlite.js'; diff --git a/packages/data/src/sqlite.ts b/packages/data/src/sqlite.ts index 5ceeeef..afaeada 100644 --- a/packages/data/src/sqlite.ts +++ b/packages/data/src/sqlite.ts @@ -5,9 +5,31 @@ import { randomUUID } from 'node:crypto'; import type { BookingRepository } from './index.js'; import type { DemoBooking, DemoDashboard, DemoEvent } from '@dispatch/contracts'; -export const DEMO_DATE = '2026-09-19'; -export const demoTime = (time: string) => `${DEMO_DATE}T${time}:00-07:00`; -export const localTime = (iso: string) => new Date(iso).toLocaleTimeString('en-GB', { timeZone: 'America/Los_Angeles', hour: '2-digit', minute: '2-digit' }); +export const TIMEZONE = 'America/Los_Angeles'; + +const localDate = (at: Date) => + new Intl.DateTimeFormat('en-CA', { timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit' }).format(at); + +// The demo day is tomorrow in the shop's timezone, so every offered time is still +// in the future however late in the day the demo runs. +export function demoDate(now = new Date()): string { + const day = new Date(`${localDate(now)}T00:00:00Z`); + day.setUTCDate(day.getUTCDate() + 1); + return day.toISOString().slice(0, 10); +} + +// Real offset for that date, so the seeded times survive a DST change. +function utcOffset(date: string): string { + const name = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, timeZoneName: 'longOffset' }) + .formatToParts(new Date(`${date}T12:00:00Z`)) + .find((part) => part.type === 'timeZoneName')?.value ?? 'GMT'; + return name.replace('GMT', '') || '+00:00'; +} + +export const DEMO_DATE = demoDate(); +const DEMO_OFFSET = utcOffset(DEMO_DATE); +export const demoTime = (time: string) => `${DEMO_DATE}T${time}:00${DEMO_OFFSET}`; +export const localTime = (iso: string) => new Date(iso).toLocaleTimeString('en-GB', { timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit' }); // A single-barber calendar, backed by Node 24's built-in SQLite. No external service. export class SqliteBookings implements BookingRepository { @@ -18,7 +40,10 @@ export class SqliteBookings implements BookingRepository { this.db.exec(`CREATE TABLE IF NOT EXISTS clients (id TEXT PRIMARY KEY, name TEXT, phone TEXT, waitlist INTEGER, booked INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS bookings (id TEXT PRIMARY KEY, data TEXT NOT NULL, idempotency_key TEXT UNIQUE); CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT, message TEXT);`); - if (!this.db.prepare('SELECT id FROM clients LIMIT 1').get()) this.reset(); + // A database kept from an earlier demo day holds past times; reseed it onto today's demo date. + const seeded = this.db.prepare('SELECT data FROM bookings LIMIT 1').get(); + const staleDay = !!seeded && !(JSON.parse(String(seeded.data)) as DemoBooking).startsAt.startsWith(DEMO_DATE); + if (staleDay || !this.db.prepare('SELECT id FROM clients LIMIT 1').get()) this.reset(); } close() { this.db.close(); } reset() { diff --git a/packages/voice/src/agent-config.ts b/packages/voice/src/agent-config.ts index 6a327c8..8e4bab4 100644 --- a/packages/voice/src/agent-config.ts +++ b/packages/voice/src/agent-config.ts @@ -14,6 +14,18 @@ export const endCallTool: ElevenLabs.SystemToolConfigInput = { export const naturalSpeechInstructions = `Speak dates and times like a person throughout the call, including reminders and booking confirmations. Use {{date}} as the appointment date for speech; it is already formatted in the business timezone. Say "today" or "tomorrow" when supplied, otherwise the named day and month. Never read ISO dates, numeric date strings, years, or timezone identifiers aloud. Tool results and manager briefs may contain machine-formatted dates; use the spoken appointment date instead. Speak times naturally, such as "three PM" or "three thirty PM", never "fifteen colon zero zero". Keep reminders short: "Does three PM work for you?" Avoid repeating the full date, service and price unless the customer asks.`; +export const bookingDecisionInstructions = `A clear yes to the time on the table is explicit agreement. "Yes", "sure", "that works", "I'll take it" all mean yes: call accept_slot for that time immediately. Never ask the customer to confirm a time they already accepted, and never re-ask after a yes. Ask one short clarifying question only when the answer is genuinely ambiguous, such as "maybe" or "what else do you have?". +A no is final. A refusal means declining the appointment, not requesting a different time: "No, could I do three thirty?" means call check_availability and continue. On a refusal without a request for another time, call decline_slot straight away, thank them in one sentence, and end the call. After a decline never offer another time, never repeat the offer, never ask why, and never give reasons to reconsider. Mention other times only if the customer asks for a different time themselves. +After accept_slot or decline_slot returns ok: true, say one short goodbye and use end_call. Do not wait for another reply.`; + +// Sentences from earlier prompt versions. Stripped before the current instructions +// are applied, so an updated agent never carries contradictory rules. +export const supersededInstructions = [ + 'After a confirmed booking or decline, say a short goodbye. The customer can end the web call.', + 'Before confirming any booking, obtain explicit agreement to the exact time, then call accept_slot.', + 'If the customer declines the offer, call decline_slot and thank them. Do not pressure them.', +]; + export const discountInstructions = `The approved discount for this appointment is {{discount}}. If it is non-empty, explicitly highlight this discount in the initial offer, including its supplied amount or percentage. The quoted {{price}} is already the final price; do not subtract the discount again. If no discount is supplied, do not mention a discount or imply there is one. Never invent, increase, or negotiate discounts. An approved discount may be mentioned even if an older manager brief says "offer discounts" is forbidden; that restriction applies only to additional, unapproved discounts.`; export const dispatchFirstMessage = 'Hi {{customer_name}}, I’m an AI assistant for {{business_name}}. We have a {{service}} opening for {{date}} at {{offered_time}} for {{price}}.{{discount_offer}} Would you like it?'; @@ -26,9 +38,8 @@ ${naturalSpeechInstructions} ${discountInstructions} Be brief, warm, and conversational. Ask one question at a time. Disclose that you are an AI assistant. If the customer requests another time, call check_availability with the local 24-hour HH:mm time. -Before confirming any booking, obtain explicit agreement to the exact time, then call accept_slot. +${bookingDecisionInstructions} Only say a booking is confirmed if accept_slot returns ok: true. If a tool fails, say you could not confirm it. -If the customer declines the offer, call decline_slot and thank them. Do not pressure them. Never invent services or availability, reveal who cancelled, or claim a real payment was processed. This is a demo: calendar and fee changes are simulated. Do not talk about the original customer's fee to this customer. ${callEndingInstructions} @@ -42,6 +53,6 @@ const timeParameters: ElevenLabs.ObjectJsonSchemaPropertyInput = { export const dispatchTools: ElevenLabs.ToolRequestModel[] = [ { toolConfig: { type: 'client', name: 'check_availability', description: 'Check whether a requested appointment time is available.', expectsResponse: true, responseTimeoutSecs: 15, parameters: timeParameters } }, - { toolConfig: { type: 'client', name: 'accept_slot', description: 'Book the exact time only after the customer explicitly agrees. Wait for ok: true before confirming.', expectsResponse: true, responseTimeoutSecs: 15, parameters: timeParameters } }, + { toolConfig: { type: 'client', name: 'accept_slot', description: 'Book the exact time as soon as the customer agrees to it. Wait for ok: true before confirming.', expectsResponse: true, responseTimeoutSecs: 15, parameters: timeParameters } }, { toolConfig: { type: 'client', name: 'decline_slot', description: 'Record that the customer explicitly declined the appointment.', expectsResponse: true, responseTimeoutSecs: 15, parameters: { type: 'object', properties: {}, required: [] } } }, ]; diff --git a/packages/voice/src/update-agent.ts b/packages/voice/src/update-agent.ts index d6dbeb3..5b83f27 100644 --- a/packages/voice/src/update-agent.ts +++ b/packages/voice/src/update-agent.ts @@ -1,5 +1,5 @@ import type { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; -import { callEndingInstructions, discountInstructions, dispatchFirstMessage, endCallTool, naturalSpeechInstructions } from './agent-config.js'; +import { bookingDecisionInstructions, callEndingInstructions, discountInstructions, dispatchFirstMessage, endCallTool, naturalSpeechInstructions, supersededInstructions } from './agent-config.js'; // Update speech and ending policy; keep voice, LLM, client tools and custom instructions. export async function enableAutomaticHangup(client: ElevenLabsClient, agentId: string) { @@ -8,17 +8,17 @@ export async function enableAutomaticHangup(client: ElevenLabsClient, agentId: s const placeholders = (current.conversationConfig.agent?.dynamicVariables as { dynamic_variable_placeholders?: Record; } | undefined)?.dynamic_variable_placeholders; - const existing = (prompt?.prompt ?? '').replace( - 'After a confirmed booking or decline, say a short goodbye. The customer can end the web call.', '', - ).replace('Never offer discounts, invent services or availability,', 'Never invent services or availability,').trim(); - const updated = [callEndingInstructions, naturalSpeechInstructions, discountInstructions].reduce( + const existing = supersededInstructions + .reduce((text, sentence) => text.replaceAll(sentence, ''), prompt?.prompt ?? '') + .replaceAll('Never offer discounts, invent services or availability,', 'Never invent services or availability,') + .replace(/[^\S\n]+\n/g, '\n').replace(/\n{2,}/g, '\n').trim(); + const updated = [callEndingInstructions, naturalSpeechInstructions, discountInstructions, bookingDecisionInstructions].reduce( (text, instructions) => text.includes(instructions) ? text : `${text}\n${instructions}`, existing, ); await client.conversationalAi.agents.update(agentId, { conversationConfig: { agent: { firstMessage: dispatchFirstMessage, dynamicVariables: { dynamic_variable_placeholders: { - ...placeholders, - discount: '', discount_offer: '', + ...placeholders, discount: '', discount_offer: '', } }, prompt: { prompt: updated, builtInTools: { ...prompt?.builtInTools, endCall: endCallTool }, @@ -29,6 +29,8 @@ export async function enableAutomaticHangup(client: ElevenLabsClient, agentId: s || !verified.conversationConfig.agent.prompt.prompt?.includes(callEndingInstructions) || !verified.conversationConfig.agent.prompt.prompt?.includes(naturalSpeechInstructions) || !verified.conversationConfig.agent.prompt.prompt?.includes(discountInstructions) + || !verified.conversationConfig.agent.prompt.prompt?.includes(bookingDecisionInstructions) + || supersededInstructions.some((sentence) => verified.conversationConfig.agent?.prompt?.prompt?.includes(sentence)) || verified.conversationConfig.agent.firstMessage !== dispatchFirstMessage) { throw new Error('Voice agent configuration was not saved.'); } diff --git a/packages/voice/test/agent-config.test.ts b/packages/voice/test/agent-config.test.ts index f02c0b4..7512bd6 100644 --- a/packages/voice/test/agent-config.test.ts +++ b/packages/voice/test/agent-config.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; import { dynamicVariables } from '../dist/demo.js'; -import { dispatchFirstMessage } from '../dist/agent-config.js'; +import { bookingDecisionInstructions, dispatchFirstMessage, dispatchPrompt, supersededInstructions } from '../dist/agent-config.js'; import { enableAutomaticHangup } from '../dist/update-agent.js'; test('existing agent gains natural speech and end_call while preserving custom instructions; updates are repeatable', async () => { @@ -11,7 +11,7 @@ test('existing agent gains natural speech and end_call while preserving custom i const agent = { agent_id: 'agent_test', name: 'Dispatch', metadata: { created_at_unix_secs: 1, updated_at_unix_secs: 1 }, conversation_config: { tts: { voice_id: 'existing-voice' }, agent: { first_message: 'Existing greeting', dynamic_variables: { dynamic_variable_placeholders: { business_name: 'Existing shop' } }, prompt: { - prompt: 'Custom shop instructions.\nNever offer discounts, invent services or availability, reveal who cancelled, or claim a real payment was processed.\nAfter a confirmed booking or decline, say a short goodbye. The customer can end the web call.', + prompt: 'Custom shop instructions.\nNever offer discounts, invent services or availability, reveal who cancelled, or claim a real payment was processed.\nAfter a confirmed booking or decline, say a short goodbye. The customer can end the web call.\nBefore confirming any booking, obtain explicit agreement to the exact time, then call accept_slot.\nIf the customer declines the offer, call decline_slot and thank them. Do not pressure them.', llm: 'gemini-2.0-flash', tool_ids: ['check', 'accept', 'decline'], built_in_tools: { skip_turn: { type: 'system', name: 'skip_turn', params: { system_tool_type: 'skip_turn' } } }, } }, @@ -39,6 +39,9 @@ test('existing agent gains natural speech and end_call while preserving custom i assert.match(patch.prompt, /explicitly highlight this discount in the initial offer/); assert.doesNotMatch(patch.prompt, /Never offer discounts/); assert.doesNotMatch(patch.prompt, /The customer can end the web call/); + assert.match(patch.prompt, /all mean yes: call accept_slot for that time immediately/); + assert.match(patch.prompt, /A no is final/); + for (const sentence of supersededInstructions) assert.ok(!patch.prompt.includes(sentence), `superseded: ${sentence}`); if (updates) assert.equal(patch.prompt, agent.conversation_config.agent.prompt.prompt); Object.assign(agent.conversation_config.agent.prompt, patch); updates++; @@ -80,3 +83,13 @@ test('the opening highlights supplied discounts and omits absent or blank discou } } }); + +test('the prompt tells the agent to book on a plain yes and never push after a no', () => { + assert.ok(dispatchPrompt.includes(bookingDecisionInstructions), 'setup prompt carries the decision rules'); + assert.match(bookingDecisionInstructions, /Never ask the customer to confirm a time they already accepted/); + assert.match(bookingDecisionInstructions, /After a decline never offer another time/); + assert.match(bookingDecisionInstructions, /Mention other times only if the customer asks/); + // Both outcomes must hang up, not just the decline. + assert.match(bookingDecisionInstructions, /After accept_slot or decline_slot returns ok: true, say one short goodbye and use end_call/); + for (const sentence of supersededInstructions) assert.ok(!dispatchPrompt.includes(sentence), `superseded: ${sentence}`); +});