From 3dae8c8bbacf9d16b8e8345d7b3207f309561556 Mon Sep 17 00:00:00 2001 From: lucasyoo1 Date: Sat, 19 Sep 2026 15:02:03 -0700 Subject: [PATCH] fix: book on a plain yes, never push after a no, and always hang up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing surfaced four voice agent problems: - Double confirmation: the prompt asked for "explicit agreement" before accept_slot, which the agent read as needing a second yes. A clear yes is now explicit agreement; it books immediately and never re-asks. - Pushing after a decline: the agent offered alternatives unprompted. A no is now final — decline_slot, one sentence of thanks, end. Other times come up only if the customer asks. - Unreliable hangup: end_call was instructed for both outcomes but only enforced on the decline path. The instruction now covers both, and the web client ends the session itself if the agent keeps talking after a booked or declined result. - Past times: the demo date was hardcoded to a fixed day, so offered slots were often in the past. It is now tomorrow in the shop's timezone, with the real UTC offset for that date, and a database left from an earlier demo day reseeds onto the new date. The dashboard reads the same DEMO_DATE. npm run voice:update pushes the new rules to an existing agent; superseded sentences from earlier prompt versions are stripped first so an updated agent never carries contradictory instructions. Co-Authored-By: Claude Opus 5 (1M context) --- apps/api/test/time.test.ts | 14 ++++++++++ apps/api/test/voice.test.ts | 4 +-- apps/web/src/voice/VoiceDemo.tsx | 12 +++++++++ packages/data/src/index.ts | 2 +- packages/data/src/sqlite.ts | 33 +++++++++++++++++++++--- packages/voice/src/agent-config.ts | 17 +++++++++--- packages/voice/src/update-agent.ts | 12 +++++---- packages/voice/test/agent-config.test.ts | 18 ++++++++++--- 8 files changed, 94 insertions(+), 18 deletions(-) diff --git a/apps/api/test/time.test.ts b/apps/api/test/time.test.ts index cf49cea..40e3190 100644 --- a/apps/api/test/time.test.ts +++ b/apps/api/test/time.test.ts @@ -1,6 +1,7 @@ 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 +25,16 @@ 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}$/); +}); diff --git a/apps/api/test/voice.test.ts b/apps/api/test/voice.test.ts index 13200ae..b3c5946 100644 --- a/apps/api/test/voice.test.ts +++ b/apps/api/test/voice.test.ts @@ -3,7 +3,7 @@ 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 { DEMO_DATE, SqliteBookings, demoTime } from '@dispatch/data'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -55,7 +55,7 @@ test('shared HTTP demo: cancellation → offer → voice tool → calendar booki 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.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'); diff --git a/apps/web/src/voice/VoiceDemo.tsx b/apps/web/src/voice/VoiceDemo.tsx index 26540e2..20e2463 100644 --- a/apps/web/src/voice/VoiceDemo.tsx +++ b/apps/web/src/voice/VoiceDemo.tsx @@ -6,6 +6,9 @@ import { AppHeader } from '../AppHeader'; import { CalendarDays, MessagesSquare } from 'lucide-react'; import './voice.css'; +// 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', @@ -31,6 +34,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); @@ -50,6 +54,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(() => {}); @@ -108,6 +113,12 @@ 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 ((action === 'accept' || action === 'decline') && isCurrent()) { + if (hangup.current) clearTimeout(hangup.current); + hangup.current = setTimeout(() => { void connection.current?.endSession(); }, GOODBYE_MS); + } return JSON.stringify({ ok: true, ...result }); } catch (err) { const message = err instanceof Error ? err.message : 'Could not complete the request'; @@ -135,6 +146,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/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 e9f8d34..15744d2 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. On any refusal, 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 dispatchFirstMessage = 'Hi {{customer_name}}, I’m an AI assistant for {{business_name}}. We have a {{service}} opening for {{date}} at {{offered_time}} for {{price}}. Would you like it?'; export const dispatchPrompt = `You are Dispatch, a friendly AI booking assistant for {{business_name}}. @@ -23,9 +35,8 @@ Available alternatives for this demo are {{available_times}}. ${naturalSpeechInstructions} 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 offer discounts, 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} @@ -39,6 +50,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 52f9544..4f6fb47 100644 --- a/packages/voice/src/update-agent.ts +++ b/packages/voice/src/update-agent.ts @@ -1,14 +1,14 @@ import type { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; -import { callEndingInstructions, dispatchFirstMessage, endCallTool, naturalSpeechInstructions } from './agent-config.js'; +import { bookingDecisionInstructions, callEndingInstructions, 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) { const current = await client.conversationalAi.agents.get(agentId); const prompt = current.conversationConfig.agent?.prompt; - const existing = (prompt?.prompt ?? '').replace( - 'After a confirmed booking or decline, say a short goodbye. The customer can end the web call.', '', - ).trim(); - const updated = [callEndingInstructions, naturalSpeechInstructions].reduce( + const existing = supersededInstructions + .reduce((text, sentence) => text.replaceAll(sentence, ''), prompt?.prompt ?? '') + .replace(/[^\S\n]+\n/g, '\n').replace(/\n{2,}/g, '\n').trim(); + const updated = [callEndingInstructions, naturalSpeechInstructions, bookingDecisionInstructions].reduce( (text, instructions) => text.includes(instructions) ? text : `${text}\n${instructions}`, existing, ); await client.conversationalAi.agents.update(agentId, { @@ -21,6 +21,8 @@ export async function enableAutomaticHangup(client: ElevenLabsClient, agentId: s if (verified.conversationConfig.agent?.prompt?.builtInTools?.endCall?.params.systemToolType !== 'end_call' || !verified.conversationConfig.agent.prompt.prompt?.includes(callEndingInstructions) || !verified.conversationConfig.agent.prompt.prompt?.includes(naturalSpeechInstructions) + || !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 169e15e..0e551a2 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', prompt: { - prompt: 'Custom shop instructions.\nAfter a confirmed booking or decline, say a short goodbye. The customer can end the web call.', + prompt: 'Custom shop instructions.\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' } } }, } }, @@ -33,7 +33,9 @@ test('existing agent gains natural speech and end_call while preserving custom i assert.match(patch.prompt, /Custom shop instructions/); assert.match(patch.prompt, /Never read ISO dates/); assert.match(patch.prompt, /Keep reminders short/); - 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++; @@ -56,3 +58,13 @@ test('the greeting speaks the local day and actual offered time, without an ISO assert.match(greeting, /for today at 3:30 PM for \$45/); assert.doesNotMatch(greeting, /2026-09-19|15:30|{{/); }); + +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}`); +});