From 9bdaa8505666c7a0d7fce314f1093d55e796dafd Mon Sep 17 00:00:00 2001 From: Morrow Contributors Date: Sat, 8 Aug 2026 10:57:24 +0800 Subject: [PATCH] feat: make assistant support flows survive real customers and real developers Field evidence from a live customer conversation showed four compounding failures: the deterministic order intake only triggered on the literal word 'order', the customer's contact and pending flow lived on the WebSocket connection and died with every mobile drop, a bare order-number reply fell through to the topic guardrail, and store order-name formats with slashes could not be parsed at all. There was also no way to exercise any of this outside production because strict Turnstile claim checks reject Cloudflare's documented testing keys. - Conversation state (contact, verification, pending escalation and order flow) now lives in Durable Object session storage; a dropped socket keeps history and state for a ten-minute grace period and resumes mid-flow on reconnect, then wipes if nobody returns. - The deterministic order intake matches shipment nouns (delivery, package, parcel, shipment) near tracking signals, not just 'order'. - A message that is nothing but an order number always enters the order flow: with contact on file it looks up directly; without, it requests the contact card once. Order numbers may contain '/' (fiscal-year store formats). - On the local surface only, a successful siteverify under Cloudflare's documented Turnstile testing secrets is accepted without claim checks, so the full widget flow is testable before production keys exist. Non-local surfaces keep strict verification. - The voice-eval harness resolves wrangler through the package instead of a workspace-local path npm hoists away; the mandated eval:voice gate was unrunnable on a fresh install. Order-result prompt guidance strengthened; evals pass 12/13 with the adversarial unfamiliar-product case remaining model-flaky (fails without searching; tracked as follow-up). - Developer-machine hygiene: the vitest pool pins the bindings a local .dev.vars would leak into tests, and the public-readiness scan judges what git can publish instead of failing on properly ignored local state. Co-Authored-By: Claude Fable 5 --- apps/desk/scripts/run-voice-evals.mjs | 10 +- apps/desk/src/security/public-write.ts | 15 ++ apps/desk/src/voice/conversation.ts | 4 +- apps/desk/src/voice/demo-agent.ts | 166 ++++++++++++++--------- apps/desk/src/voice/orders.ts | 30 +++- apps/desk/test/settings-security.test.ts | 35 +++++ apps/desk/test/voice-agent.test.ts | 59 +++++++- apps/desk/test/voice-orders.test.ts | 35 +++++ apps/desk/vitest.config.ts | 7 + scripts/scan-public-readiness.mjs | 32 ++++- 10 files changed, 321 insertions(+), 72 deletions(-) diff --git a/apps/desk/scripts/run-voice-evals.mjs b/apps/desk/scripts/run-voice-evals.mjs index fbc9c43..5d369aa 100644 --- a/apps/desk/scripts/run-voice-evals.mjs +++ b/apps/desk/scripts/run-voice-evals.mjs @@ -1,4 +1,6 @@ import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' const port = Number(process.env.VOICE_EVAL_PORT ?? 8794) if (!Number.isInteger(port) || port < 1024 || port > 65_535) throw new Error('VOICE_EVAL_PORT must be an unprivileged TCP port') @@ -57,8 +59,14 @@ function assertNoRepeatedSentence(reply, label) { } } +// npm workspaces may hoist wrangler to the repository root, so resolve the +// package instead of assuming a workspace-local node_modules path. The bin +// entry is not in wrangler's export map, so locate it from the package root. +const require = createRequire(import.meta.url) +const wranglerEntry = join(dirname(require.resolve('wrangler/package.json')), 'bin', 'wrangler.js') + const worker = spawn(process.execPath, [ - 'node_modules/wrangler/bin/wrangler.js', + wranglerEntry, 'dev', '--config', 'wrangler.voice-eval.jsonc', diff --git a/apps/desk/src/security/public-write.ts b/apps/desk/src/security/public-write.ts index 884f3e6..616371b 100644 --- a/apps/desk/src/security/public-write.ts +++ b/apps/desk/src/security/public-write.ts @@ -30,6 +30,20 @@ function normalizedHostname(value: string): string { return value.trim().toLowerCase().replace(/\.$/, '') } +// Cloudflare's documented Turnstile testing secret keys accept or reject every +// token, but their siteverify responses do not echo the action or hostname +// claims, so the strict claim checks would fail every proof and leave the +// widget flow untestable outside production. On the local development surface +// only, a successful verification under a documented testing secret is +// accepted without claim checks. Non-local surfaces keep strict verification +// even when a testing secret is configured, so a production misconfiguration +// still fails closed. +const TURNSTILE_TESTING_SECRETS = new Set([ + '1x0000000000000000000000000000000AA', + '2x0000000000000000000000000000000AA', + '3x0000000000000000000000000000000AA', +]) + export async function verifyTurnstileProof( input: { token: string | null | undefined @@ -58,6 +72,7 @@ export async function verifyTurnstileProof( return { ok: false, reason: 'turnstile_unavailable' } } if (!result.success) return { ok: false, reason: result['error-codes']?.[0] ?? 'turnstile_failed' } + if (input.local && TURNSTILE_TESTING_SECRETS.has(env.TURNSTILE_SECRET_KEY)) return { ok: true } if (result.action !== input.action) return { ok: false, reason: 'turnstile_action_mismatch' } if (!result.hostname || normalizedHostname(result.hostname) !== normalizedHostname(input.hostname)) { return { ok: false, reason: 'turnstile_hostname_mismatch' } diff --git a/apps/desk/src/voice/conversation.ts b/apps/desk/src/voice/conversation.ts index af64f1f..fe27557 100644 --- a/apps/desk/src/voice/conversation.ts +++ b/apps/desk/src/voice/conversation.ts @@ -74,7 +74,7 @@ export function voiceAgentSystemPrompt( const contact = options.contact !== false const ordersAvailable = options.orders === true const orderCapability = ordersAvailable && contact - ? `\nYou can look up the caller's order. For order questions, ask the caller for the order number from their order confirmation email. If any caller message already includes an order number, reuse it and call get_order_status — never ask for it twice. Never ask the caller for an email address; the server already holds this session's email and matches the order automatically. If the tool returns not_found, say you could not find that order for the email on this session: suggest double-checking the number, or restarting the chat with the email used at checkout, and offer to open a ticket instead. If the tool returns unavailable, say you are having trouble checking orders right now — never say the order could not be found — and offer to open a ticket so the team can follow up. Answer order questions only from tool data — never invent order details, and never speculate about whether an order number exists for a different email. When a product question has no documented answer, offer to check the caller's order so a ticket for the team carries the exact product and purchase date. If the caller's latest message only confirms that they shared their name and email after that undocumented-product handoff, reuse an order number from any earlier caller message and call get_order_status; if none exists, ask only for the order number. Do not create a ticket or claim an order check is underway before the lookup.` + ? `\nYou can look up the caller's order. For order questions, ask the caller for the order number from their order confirmation email. If any caller message already includes an order number, reuse it and call get_order_status — never ask for it twice. Never ask the caller for an email address; the server already holds this session's email and matches the order automatically. If the tool returns not_found, say you could not find that order for the email on this session: suggest double-checking the number, or restarting the chat with the email used at checkout. If the tool returns unavailable, say you are having trouble checking orders right now — never say the order could not be found. In both cases the reply must end by offering to open a support ticket for the team; never omit that offer. Answer order questions only from tool data — never invent order details, and never speculate about whether an order number exists for a different email. When a product question has no documented answer, offer to check the caller's order so a ticket for the team carries the exact product and purchase date. If the caller's latest message only confirms that they shared their name and email after that undocumented-product handoff, reuse an order number from any earlier caller message and call get_order_status; if none exists, ask only for the order number. Do not create a ticket or claim an order check is underway before the lookup.` : ordersAvailable ? '' : `\nOrder lookup is not available in this workspace. Never claim that you can check an order, shipping status, product purchase, or purchase date. For order questions, say you cannot check orders here and offer to open a support ticket for team follow-up.` @@ -94,7 +94,7 @@ A question about warranty or another support policy is in scope even when it doe CAPABILITIES You can answer support questions from the published help-centre articles and open a support ticket for the caller. Never ask the user for their name or email in tool calls; identity is enforced by the server. -For policy or warranty questions, call search_help_center first. For how-to, product care, shipping, or troubleshooting questions, also call search_help_center first with a short topic query of two to six words. Answer only from the returned article content in at most two short sentences. The matching articles are shown to the caller as links automatically, so point them to the linked guide for the full steps. If the search returns no_match, say you do not have a documented answer for that and offer to open a ticket — do not answer such questions from memory. If it returns unavailable, say you cannot check the help articles right now and offer a ticket. Never include a URL or a link in your reply — the matching articles are already linked for the caller. +For policy or warranty questions, call search_help_center first. For how-to, product care, shipping, or troubleshooting questions, also call search_help_center first with a short topic query of two to six words. Even when you do not recognize the product or the question sounds unusual, search before deciding: never call a product or device question unsupported or out of scope without a search_help_center result for it, and never say you lack information or a documented answer unless search_help_center already returned no_match in this turn. Answer only from the returned article content in at most two short sentences. The matching articles are shown to the caller as links automatically, so point them to the linked guide for the full steps. If the search returns no_match, say you do not have a documented answer for that and offer to open a ticket — do not answer such questions from memory. If it returns unavailable, say you cannot check the help articles right now and offer a ticket. Never include a URL or a link in your reply — the matching articles are already linked for the caller. ${actionCapability} You cannot look up or report ticket status in this channel. If the caller asks about an existing ticket's status, say that updates arrive by email through their private case link and that you cannot check status here. Never invent or guess a status. Offer to open a new ticket only if they describe a new problem. diff --git a/apps/desk/src/voice/demo-agent.ts b/apps/desk/src/voice/demo-agent.ts index 7b34283..015aace 100644 --- a/apps/desk/src/voice/demo-agent.ts +++ b/apps/desk/src/voice/demo-agent.ts @@ -39,6 +39,7 @@ import { } from './conversation' import { shopifyConfigured } from '../integrations/shopify' import { + bareOrderNumber, findOrderNumber, isOrderLookupRequest, orderStatusForSession, @@ -60,6 +61,16 @@ type VoiceConnectionState = { origin?: string /** True after this connection passed the Turnstile session check. */ sessionProofPassed?: boolean +} + +/** + * Conversation-scoped state. This lives in Durable Object storage keyed by the + * session agent, NOT on the WebSocket connection: mobile browsers routinely + * drop the socket on backgrounding or screen lock, and the customer's contact + * details and pending flow must survive the client's automatic reconnect. Only + * transport facts and the per-connection Turnstile proof stay on connections. + */ +type VoiceSessionState = { contact?: VoiceContact | null /** True after the contact email was proven via the progressive OTP flow. */ verified?: boolean @@ -78,6 +89,14 @@ type VoiceConnectionState = { pendingContactReason?: 'order_lookup' | 'open_ticket' | 'product_help' | null } +const SESSION_STATE_KEY = 'voice_session_state' +/** + * How long a disconnected session keeps its history and state before being + * wiped. Long enough to ride out network drops and app switches; short enough + * that an abandoned visit does not retain contact details. + */ +const ABANDONED_SESSION_GRACE_SECONDS = 10 * 60 + function connectionState(connection: Connection): VoiceConnectionState { return connection.state && typeof connection.state === 'object' ? connection.state as VoiceConnectionState @@ -129,9 +148,28 @@ export class AbleDeskAgent extends VoiceAgent { onClose(): void { this.#activeSpeaker = null + // A dropped socket is not the end of the visit. Keep history and session + // state for a grace period so the client's automatic reconnect resumes the + // conversation mid-flow, then wipe everything if nobody came back. + void this.schedule(ABANDONED_SESSION_GRACE_SECONDS, 'cleanupAbandonedSession') + } + + async cleanupAbandonedSession(): Promise { + if ([...this.getConnections()].length > 0) return + await this.ctx.storage.delete(SESSION_STATE_KEY) this.#clearHistory() } + async #session(): Promise { + return await this.ctx.storage.get(SESSION_STATE_KEY) ?? {} + } + + async #patchSession(patch: Partial): Promise { + const next = { ...await this.#session(), ...patch } + await this.ctx.storage.put(SESSION_STATE_KEY, next) + return next + } + async onMessage(connection: Connection, message: WSMessage): Promise { if (typeof message !== 'string') return try { @@ -165,9 +203,7 @@ export class AbleDeskAgent extends VoiceAgent { return } if (parsed.type === 'clear_voice_identity') { - const state = connectionState(connection) - connection.setState({ - ...state, + await this.#patchSession({ contact: null, verified: false, otpChallenge: null, @@ -177,17 +213,16 @@ export class AbleDeskAgent extends VoiceAgent { otpOperationId: null, pendingEscalation: null, pendingContactReason: null, - } satisfies VoiceConnectionState) + }) this.#clearHistory() connection.send(JSON.stringify({ type: 'voice_identity_cleared' })) return } if (parsed.type !== 'clear_demo_session') return - connection.setState({ - ...connectionState(connection), + await this.#patchSession({ pendingEscalation: null, pendingContactReason: null, - } satisfies VoiceConnectionState) + }) this.#clearHistory() connection.send(JSON.stringify({ type: 'demo_session_cleared' })) } catch { @@ -211,7 +246,8 @@ export class AbleDeskAgent extends VoiceAgent { return 'You are sending messages very quickly. Give it a minute, then send that again.' } - const contact = this.#contact(context.connection) + const session = await this.#session() + const contact = hasVoiceContact(session) ? session.contact : null const turnRequestId = `voice-turn-${crypto.randomUUID()}` const messages = context.messages.map(({ role, content }) => ({ role, content })) const classifiedCategory = classifyEscalation(transcript) @@ -220,14 +256,13 @@ export class AbleDeskAgent extends VoiceAgent { : classifiedCategory if (deterministicCategory) { if (!contact) { - context.connection.setState({ - ...state, + await this.#patchSession({ pendingEscalation: { category: deterministicCategory, customerMessage: transcript, requestId: turnRequestId, }, - } satisfies VoiceConnectionState) + }) context.connection.send(JSON.stringify({ type: 'voice_contact_required', anchor: 'after_reply', reason: 'open_ticket' })) return 'This needs a person from the team to review it. Add your email in the card below and I will open a ticket for you right away.' } @@ -247,14 +282,26 @@ export class AbleDeskAgent extends VoiceAgent { const ordersAvailable = shopifyConfigured(this.env) if (!contact && ordersAvailable && isOrderLookupRequest(transcript)) { - context.connection.setState({ - ...state, - pendingContactReason: 'order_lookup', - } satisfies VoiceConnectionState) + await this.#patchSession({ pendingContactReason: 'order_lookup' }) context.connection.send(JSON.stringify({ type: 'voice_contact_required', anchor: 'after_reply', reason: 'order_lookup' })) return 'To look up your order, add your name and the email used at checkout in the card below.' } + // A message that is nothing but an order number is always the order flow — + // typically the answer to "what is the order number?", possibly arriving + // on a fresh connection after a network drop. It must never fall through + // to the model's topic guardrail. + const bareNumber = ordersAvailable ? bareOrderNumber(transcript) : null + if (bareNumber) { + if (!contact) { + await this.#patchSession({ pendingContactReason: 'order_lookup' }) + context.connection.send(JSON.stringify({ type: 'voice_contact_required', anchor: 'after_reply', reason: 'order_lookup' })) + return `To look up order ${bareNumber}, add your name and the email used at checkout in the card below.` + } + const result = await orderStatusForSession(this.env, { email: contact.email }, bareNumber) + return orderStatusReply(result) + } + const isOrderContinuation = transcript.trim() === ORDER_LOOKUP_CONTACT_CONTINUATION || transcript.trim() === PRODUCT_HELP_CONTACT_CONTINUATION if (contact && ordersAvailable && isOrderContinuation) { @@ -315,9 +362,9 @@ export class AbleDeskAgent extends VoiceAgent { orderNumber: z.string().min(1).max(32).describe("The customer's order number from their confirmation email, e.g. #1234."), }), execute: async ({ orderNumber }) => { - const state = connectionState(context.connection) + const current = await this.#session() return orderStatusForSession(this.env, { - email: hasVoiceContact(state) ? state.contact.email : null, + email: hasVoiceContact(current) ? current.contact.email : null, }, orderNumber) }, }), @@ -363,10 +410,7 @@ export class AbleDeskAgent extends VoiceAgent { const continuationReason = reason === 'product_help' && !ordersAvailable ? 'open_ticket' : reason - context.connection.setState({ - ...connectionState(context.connection), - pendingContactReason: continuationReason, - } satisfies VoiceConnectionState) + await this.#patchSession({ pendingContactReason: continuationReason }) context.connection.send(JSON.stringify({ type: 'voice_contact_required', anchor: 'after_reply', reason: continuationReason })) return reason === 'product_help' ? { @@ -457,10 +501,10 @@ export class AbleDeskAgent extends VoiceAgent { connection.send(JSON.stringify({ type: 'voice_contact_error', reason: 'invalid_contact' })) return } - const pendingEscalation = state.pendingEscalation ?? null - const pendingContactReason = state.pendingContactReason ?? null - connection.setState({ - ...connectionState(connection), + const session = await this.#session() + const pendingEscalation = session.pendingEscalation ?? null + const pendingContactReason = session.pendingContactReason ?? null + await this.#patchSession({ contact, // A new contact is always unverified; any pending challenge is stale. verified: false, @@ -469,7 +513,7 @@ export class AbleDeskAgent extends VoiceAgent { otpResendAt: 0, pendingEscalation: null, pendingContactReason: null, - } satisfies VoiceConnectionState) + }) connection.send(JSON.stringify({ type: 'voice_contact_set', contact: { name: contact.name, email: contact.email }, @@ -491,22 +535,23 @@ export class AbleDeskAgent extends VoiceAgent { async #requestVerification(connection: Connection, input: Record): Promise { const state = connectionState(connection) - const contact = hasVoiceContact(state) ? state.contact : null + const session = await this.#session() + const contact = hasVoiceContact(session) ? session.contact : null if (!contact) { connection.send(JSON.stringify({ type: 'voice_verification_error', reason: 'contact_required' })) return } const now = Date.now() - if ((state.otpResendAt ?? 0) > now) { + if ((session.otpResendAt ?? 0) > now) { connection.send(JSON.stringify({ type: 'voice_verification_error', reason: 'cooldown' })) return } - if (state.otpBusy) { + if (session.otpBusy) { connection.send(JSON.stringify({ type: 'voice_verification_error', reason: 'request_in_progress' })) return } const operationId = crypto.randomUUID() - connection.setState({ ...state, otpBusy: true, otpOperationId: operationId } satisfies VoiceConnectionState) + await this.#patchSession({ otpBusy: true, otpOperationId: operationId }) try { const [ipRate, emailRate] = await Promise.all([ this.env.PUBLIC_RATE_LIMIT.limit({ key: `voice_verify_ip:${state.clientIp ?? 'unknown'}` }), @@ -545,46 +590,45 @@ export class AbleDeskAgent extends VoiceAgent { text: `Your verification code is ${issued.code}. It expires in 10 minutes. If you did not request this code, you can ignore this email.`, headers: { 'Auto-Submitted': 'auto-generated', Organization: settings.displayName }, }) - const latest = connectionState(connection) + const latest = await this.#session() if (latest.otpOperationId !== operationId) return - connection.setState({ - ...latest, + await this.#patchSession({ otpChallenge: issued.challenge, otpAttempts: 0, otpResendAt: now + 60_000, otpBusy: false, otpOperationId: null, verified: false, - } satisfies VoiceConnectionState) + }) connection.send(JSON.stringify({ type: 'voice_verification_sent', emailHint: contact.email.replace(/^(.{1,2}).*(@.*)$/, '$1•••$2'), expiresAt: issued.challenge.expiresAt, })) } finally { - const latest = connectionState(connection) + const latest = await this.#session() if (latest.otpOperationId === operationId) { - connection.setState({ ...latest, otpBusy: false, otpOperationId: null } satisfies VoiceConnectionState) + await this.#patchSession({ otpBusy: false, otpOperationId: null }) } } } async #verifyCode(connection: Connection, code: unknown): Promise { const state = connectionState(connection) - const challenge = state.otpChallenge - const attempts = state.otpAttempts ?? 0 - if (!challenge || attempts >= 5 || typeof code !== 'string' || state.otpBusy) { + const session = await this.#session() + const challenge = session.otpChallenge + const attempts = session.otpAttempts ?? 0 + if (!challenge || attempts >= 5 || typeof code !== 'string' || session.otpBusy) { connection.send(JSON.stringify({ type: 'voice_verification_error', reason: 'invalid_or_expired_code' })) return } const operationId = crypto.randomUUID() const nextAttempts = attempts + 1 - connection.setState({ - ...state, + await this.#patchSession({ otpAttempts: nextAttempts, otpBusy: true, otpOperationId: operationId, - } satisfies VoiceConnectionState) + }) try { const [ipRate, emailRate] = await Promise.all([ this.env.PUBLIC_RATE_LIMIT.limit({ key: `voice_verify_code_ip:${state.clientIp ?? 'unknown'}` }), @@ -595,32 +639,30 @@ export class AbleDeskAgent extends VoiceAgent { return } const valid = await verifyVoiceVerificationCode(challenge, code.trim(), this.#secret(state), Date.now()) - const latest = connectionState(connection) + const latest = await this.#session() if (latest.otpOperationId !== operationId) return const currentEmail = hasVoiceContact(latest) ? latest.contact.email : null if (!valid || currentEmail !== challenge.contact.email) { - connection.setState({ - ...latest, + await this.#patchSession({ otpChallenge: nextAttempts >= 5 ? null : challenge, otpBusy: false, otpOperationId: null, - } satisfies VoiceConnectionState) + }) connection.send(JSON.stringify({ type: 'voice_verification_error', reason: 'invalid_or_expired_code' })) return } - connection.setState({ - ...latest, + await this.#patchSession({ verified: true, otpChallenge: null, otpAttempts: 0, otpBusy: false, otpOperationId: null, - } satisfies VoiceConnectionState) + }) connection.send(JSON.stringify({ type: 'voice_verified', email: challenge.contact.email })) } finally { - const latest = connectionState(connection) + const latest = await this.#session() if (latest.otpOperationId === operationId) { - connection.setState({ ...latest, otpBusy: false, otpOperationId: null } satisfies VoiceConnectionState) + await this.#patchSession({ otpBusy: false, otpOperationId: null }) } } } @@ -630,10 +672,6 @@ export class AbleDeskAgent extends VoiceAgent { return isLocalHostname(state.hostname ?? '') || Array.isArray(this.env.TEST_MIGRATIONS) ? LOCAL_SECRET : '' } - #contact(connection: Connection): VoiceContact | null { - const state = connectionState(connection) - return hasVoiceContact(state) ? state.contact : null - } async #helpdesk(connection: Connection): Promise { const state = connectionState(connection) @@ -656,10 +694,11 @@ export class AbleDeskAgent extends VoiceAgent { requestId: string, ): Promise<{ ref: CaseRef; created: boolean; delivery: DeliveryState | null }> { const state = connectionState(connection) + const session = await this.#session() const now = Date.now() - const recent = (state.ticketCreatedAt ?? []).filter((createdAt) => createdAt > now - 15 * 60_000) - if (state.ticketBusy) throw new Error('Voice ticket creation is already in progress') - connection.setState({ ...state, ticketBusy: true } satisfies VoiceConnectionState) + const recent = (session.ticketCreatedAt ?? []).filter((createdAt) => createdAt > now - 15 * 60_000) + if (session.ticketBusy) throw new Error('Voice ticket creation is already in progress') + await this.#patchSession({ ticketBusy: true }) try { const [ipRate, emailRate] = await Promise.all([ this.env.PUBLIC_RATE_LIMIT.limit({ key: `voice_ticket_ip:${state.clientIp ?? 'unknown'}` }), @@ -671,26 +710,25 @@ export class AbleDeskAgent extends VoiceAgent { if (capacity === 'limited') throw new Error('Voice ticket limit reached') const ticket = await openVoiceSupportCase(await this.#helpdesk(connection), contact, { ...input, requestId }) - if (ticket.created) connection.setState({ - ...connectionState(connection), + if (ticket.created) await this.#patchSession({ ticketCreatedAt: [...recent, now], ticketBusy: false, - } satisfies VoiceConnectionState) + }) connection.send(JSON.stringify({ type: 'voice_ticket_created', ticket: { reference: ticket.ref, label: 'Human review requested', status: 'open' }, })) return ticket } finally { - const latest = connectionState(connection) - if (latest.ticketBusy) connection.setState({ ...latest, ticketBusy: false } satisfies VoiceConnectionState) + const latest = await this.#session() + if (latest.ticketBusy) await this.#patchSession({ ticketBusy: false }) } } #openEscalationTicket( connection: Connection, contact: VoiceContact, - escalation: Pick, 'category' | 'customerMessage'>, + escalation: Pick, 'category' | 'customerMessage'>, requestId: string, ): Promise<{ ref: CaseRef; created: boolean; delivery: DeliveryState | null }> { return this.#openTicket(connection, contact, { diff --git a/apps/desk/src/voice/orders.ts b/apps/desk/src/voice/orders.ts index 51b0573..f142216 100644 --- a/apps/desk/src/voice/orders.ts +++ b/apps/desk/src/voice/orders.ts @@ -16,13 +16,37 @@ export type OrderStatusToolResult = type ConversationMessage = { role: 'user' | 'assistant'; content: string } -const ORDER_LOOKUP_REQUEST = /(?:\b(?:where|track|tracking|status|shipped|delivery|arrive|arrival)\b.{0,60}\border\b|\border\b.{0,60}\b(?:where|track|tracking|status|shipped|delivery|arrive|arrival)\b)/i -const ORDER_NUMBER = /(?:\border(?:\s+(?:number|no\.?))?\s*[:#-]?\s*((?=[A-Z0-9-]*\d)[A-Z0-9][A-Z0-9-]{1,31})\b|#((?=[A-Z0-9-]*\d)[A-Z0-9][A-Z0-9-]{1,31})\b)/i +// Customers rarely say "order" when they chase a shipment — "my delivery is +// delayed" and "where is my package" are the common phrasings — so the +// deterministic intake matches any shipment noun near a tracking signal. +const ORDER_LOOKUP_NOUN = '(?:order|delivery|package|parcel|shipment)' +const ORDER_LOOKUP_SIGNAL = '(?:where|track|tracking|status|ship(?:s|ped|ping)?|deliver(?:y|ed|ing)?|arrive(?:s|d)?|arrival|late|delay(?:s|ed)?|missing|stuck|lost)' +const ORDER_LOOKUP_REQUEST = new RegExp( + `\\b${ORDER_LOOKUP_NOUN}\\b.{0,60}\\b${ORDER_LOOKUP_SIGNAL}\\b|\\b${ORDER_LOOKUP_SIGNAL}\\b.{0,60}\\b${ORDER_LOOKUP_NOUN}\\b`, + 'i', +) +// Order names may carry store-configured prefixes and separators, including +// fiscal-year formats such as "#2026-27/7903", so "/" is part of the value. +const ORDER_NUMBER = /(?:\border(?:\s+(?:number|no\.?))?\s*[:#-]?\s*((?=[A-Z0-9/-]*\d)[A-Z0-9][A-Z0-9/-]{1,31})\b|#((?=[A-Z0-9/-]*\d)[A-Z0-9][A-Z0-9/-]{1,31})\b)/i +// A message that is nothing but an order number, as customers reply after +// being asked for one. The token must contain a digit; trailing sentence +// punctuation is tolerated. +const BARE_ORDER_NUMBER = /^#?((?=[A-Z0-9/-]*\d)[A-Z0-9][A-Z0-9/-]{1,31})[.!?]?$/i export function isOrderLookupRequest(message: string): boolean { return ORDER_LOOKUP_REQUEST.test(message) } +/** + * The order number when the whole message is one, e.g. "#2026-27/7903" or + * "2026-27/7903." typed in answer to "what is the order number?" — otherwise + * null. Normalized to the "#"-prefixed uppercase form used for lookup. + */ +export function bareOrderNumber(message: string): string | null { + const match = BARE_ORDER_NUMBER.exec(message.trim()) + return match?.[1] ? `#${match[1].toUpperCase()}` : null +} + export function findOrderNumber(messages: ConversationMessage[]): string | null { for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] @@ -30,6 +54,8 @@ export function findOrderNumber(messages: ConversationMessage[]): string | null const match = ORDER_NUMBER.exec(message.content) const value = match?.[1] ?? match?.[2] if (value) return `#${value.replace(/^#/, '').toUpperCase()}` + const bare = bareOrderNumber(message.content) + if (bare) return bare } return null } diff --git a/apps/desk/test/settings-security.test.ts b/apps/desk/test/settings-security.test.ts index 97a33a5..4a9a136 100644 --- a/apps/desk/test/settings-security.test.ts +++ b/apps/desk/test/settings-security.test.ts @@ -115,6 +115,41 @@ describe('Turnstile public-write binding', () => { expect(verified).toHaveBeenCalledOnce() }) + it('honours the documented testing secret on the local surface only', async () => { + // Cloudflare's dummy siteverify responses omit the action and hostname + // claims, so the testing secret is accepted without claim checks — but + // only for local development requests. Production hostnames keep strict + // claim verification even when a testing secret is configured. + const testingSecret = '1x0000000000000000000000000000000AA' + const dummyResponse = () => vi.fn() + .mockResolvedValue(Response.json({ success: true, hostname: 'example.com' })) + + await expect(verifyTurnstileProof({ + token: 'any-token', + action: 'voice_session', + ip: 'unknown', + hostname: 'localhost', + local: true, + }, publicEnv(testingSecret), dummyResponse())).resolves.toEqual({ ok: true }) + + await expect(verifyTurnstileProof({ + token: 'any-token', + action: 'voice_session', + ip: 'unknown', + hostname: 'support.example.test', + local: false, + }, publicEnv(testingSecret), dummyResponse())).resolves.toEqual({ ok: false, reason: 'turnstile_action_mismatch' }) + + // A real secret keeps strict claim checks even on the local surface. + await expect(verifyTurnstileProof({ + token: 'any-token', + action: 'voice_session', + ip: 'unknown', + hostname: 'localhost', + local: true, + }, publicEnv('real-production-secret'), dummyResponse())).resolves.toEqual({ ok: false, reason: 'turnstile_action_mismatch' }) + }) + it('accepts opaque browser origins only with same-origin fetch metadata', async () => { const verified = vi.fn() .mockResolvedValue(Response.json({ success: true, action: 'intake', hostname: 'support.example.test' })) diff --git a/apps/desk/test/voice-agent.test.ts b/apps/desk/test/voice-agent.test.ts index 45fe0e4..6764405 100644 --- a/apps/desk/test/voice-agent.test.ts +++ b/apps/desk/test/voice-agent.test.ts @@ -24,8 +24,12 @@ function nextMessage(socket: WebSocket, predicate: (value: Record { const namespace = (env as TestAgentEnv).AbleDeskAgent const stub = namespace.get(namespace.idFromName(name)) + // Every real connection carries a client IP and the public rate limits key + // on it. A unique IP per connection keeps tests from draining one shared + // rate-limit budget as the suite grows. + const bytes = crypto.getRandomValues(new Uint8Array(3)) const response = await stub.fetch(new Request(`${origin}/agents/able-desk-agent/${name}`, { - headers: { upgrade: 'websocket' }, + headers: { upgrade: 'websocket', 'CF-Connecting-IP': `10.${bytes[0]}.${bytes[1]}.${bytes[2]}` }, })) expect(response.status).toBe(101) const socket = response.webSocket @@ -178,6 +182,59 @@ describe('voice agent WebSocket boundary', () => { const result = await lookupReply expect(result).toMatchObject({ text: 'What is the order number from your confirmation email?' }) expect(String(result.text)).not.toMatch(/add your name|email.*card/i) + + // The customer answers with nothing but the order number — that is the + // order flow, never the model's topic guardrail. + const numberReply = nextMessage(socket, (message) => message.type === 'transcript_end') + socket.send(JSON.stringify({ type: 'text_message', text: '2026-27/7903.' })) + expect(String((await numberReply).text)).toContain('trouble checking orders') + } finally { + socket.close() + } + }) + + it('resumes contact and the order flow across a dropped connection', async () => { + // Mobile browsers routinely drop the WebSocket mid-flow; the session agent + // must keep the contact and pending order lookup for the reconnect. + const name = `order-reconnect-${crypto.randomUUID()}` + const first = await connectAgent(name) + try { + await proveSession(first) + const cardRequested = nextMessage(first, (message) => message.type === 'voice_contact_required') + first.send(JSON.stringify({ type: 'text_message', text: 'My delivery is delayed.' })) + await expect(cardRequested).resolves.toMatchObject({ anchor: 'after_reply', reason: 'order_lookup' }) + + const contactSet = nextMessage(first, (message) => message.type === 'voice_contact_set') + first.send(JSON.stringify({ type: 'set_voice_contact', name: 'Radha Tester', email: 'radha@example.test' })) + await expect(contactSet).resolves.toMatchObject({ continuation: 'order_lookup' }) + } finally { + first.close() + } + + const second = await connectAgent(name) + try { + await proveSession(second) + const reply = nextMessage(second, (message) => message.type === 'transcript_end') + second.send(JSON.stringify({ type: 'text_message', text: '#2026-27/7903' })) + const message = await reply + // The stored contact answers the lookup directly: no re-asking for the + // card, no off-topic refusal. + expect(String(message.text)).toContain('trouble checking orders') + expect(String(message.text)).not.toMatch(/only help with support|add your name/i) + } finally { + second.close() + } + }) + + it('asks for contact once when a bare order number arrives with nobody on file', async () => { + const socket = await connectAgent(`bare-number-${crypto.randomUUID()}`) + try { + await proveSession(socket) + const cardRequested = nextMessage(socket, (message) => message.type === 'voice_contact_required') + const reply = nextMessage(socket, (message) => message.type === 'transcript_end') + socket.send(JSON.stringify({ type: 'text_message', text: '#2026-27/7903' })) + await expect(cardRequested).resolves.toMatchObject({ reason: 'order_lookup' }) + expect(String((await reply).text)).toContain('To look up order #2026-27/7903') } finally { socket.close() } diff --git a/apps/desk/test/voice-orders.test.ts b/apps/desk/test/voice-orders.test.ts index d0356e0..54edd92 100644 --- a/apps/desk/test/voice-orders.test.ts +++ b/apps/desk/test/voice-orders.test.ts @@ -6,6 +6,7 @@ import { shopifyConfigured, } from '../src/integrations/shopify' import { + bareOrderNumber, findOrderNumber, isOrderLookupRequest, orderStatusForSession, @@ -166,6 +167,40 @@ describe('deterministic voice order continuation', () => { ])).toBe('#SO-4021') }) + it('accepts fiscal-year order names containing a slash', () => { + // Stores can configure order name formats such as "#2026-27/7903". + expect(findOrderNumber([{ role: 'user', content: '#2026-27/7903' }])).toBe('#2026-27/7903') + expect(findOrderNumber([{ role: 'user', content: 'order number 2026-27/7903' }])).toBe('#2026-27/7903') + expect(findOrderNumber([{ role: 'user', content: 'My order no. 2026-27/7903 is late' }])).toBe('#2026-27/7903') + }) + + it('recognizes delivery phrasings that never say "order"', () => { + expect(isOrderLookupRequest('My delivery is delayed.')).toBe(true) + expect(isOrderLookupRequest('Where is my package?')).toBe(true) + expect(isOrderLookupRequest("My parcel hasn't arrived")).toBe(true) + expect(isOrderLookupRequest('When will my shipment ship?')).toBe(true) + expect(isOrderLookupRequest('The tracking says my package is stuck')).toBe(true) + expect(isOrderLookupRequest('How do I return a package?')).toBe(false) + expect(isOrderLookupRequest('My grinder is broken')).toBe(false) + }) + + it('treats a message that is only an order number as one', () => { + expect(bareOrderNumber('#2026-27/7903')).toBe('#2026-27/7903') + expect(bareOrderNumber(' 2026-27/7903. ')).toBe('#2026-27/7903') + expect(bareOrderNumber('so-4021')).toBe('#SO-4021') + expect(bareOrderNumber('Where is order #4021?')).toBeNull() + expect(bareOrderNumber('thanks')).toBeNull() + expect(bareOrderNumber('')).toBeNull() + }) + + it('finds a bare order number earlier in the conversation', () => { + expect(findOrderNumber([ + { role: 'user', content: '2026-27/7903' }, + { role: 'assistant', content: 'Add your email in the card below.' }, + { role: 'user', content: 'I have shared my name and email.' }, + ])).toBe('#2026-27/7903') + }) + it('renders provider results without asking for contact details again', () => { expect(orderStatusReply({ status: 'not_found' })).toContain('email on this session') expect(orderStatusReply({ status: 'unavailable' })).toContain('trouble checking orders') diff --git a/apps/desk/vitest.config.ts b/apps/desk/vitest.config.ts index 6a6cfa3..9e3eae2 100644 --- a/apps/desk/vitest.config.ts +++ b/apps/desk/vitest.config.ts @@ -11,6 +11,13 @@ export default defineConfig({ wrangler: { configPath: './wrangler.test.jsonc' }, miniflare: { bindings: { + // The pool loads a developer's local .dev.vars from the Wrangler + // config directory. Tests assume these values are absent, so pin + // them to empty strings — every gate treats '' as unconfigured — + // to keep the suite hermetic regardless of local dev setup. + TURNSTILE_SECRET_KEY: '', + TURNSTILE_SITE_KEY: '', + CUSTOMER_CAPABILITY_SECRET: '', ABLE_DEV_EMAIL: 'owner@example.com', ABLE_OPERATOR_HOSTNAME: 'operators.example.test', SHOPIFY_SHOP_DOMAIN: 'shop.example.test', diff --git a/scripts/scan-public-readiness.mjs b/scripts/scan-public-readiness.mjs index b38fc12..16cb139 100644 --- a/scripts/scan-public-readiness.mjs +++ b/scripts/scan-public-readiness.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node +import { execFile } from 'node:child_process' import { readFile, readdir, stat } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' +import { promisify } from 'node:util' const root = process.cwd() const ignoredDirectories = new Set(['.git', '.wrangler', 'node_modules', 'dist', 'coverage', 'playwright-report', 'test-results']) @@ -28,6 +30,26 @@ async function walk(directory) { return files } +/** + * The publication risk is what git would include: tracked files plus + * untracked files not covered by .gitignore. Properly ignored local state — + * .dev.vars, local databases — cannot reach the public repository and must + * not fail the scan on a developer machine. Outside a git checkout, fall + * back to scanning the whole tree. + */ +async function publishableFiles() { + try { + const { stdout } = await promisify(execFile)( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], + { cwd: root, maxBuffer: 64 * 1024 * 1024 }, + ) + return stdout.split('\0').filter(Boolean).map((rel) => path.join(root, rel)) + } catch { + return walk(root) + } +} + function relative(filename) { return path.relative(root, filename).split(path.sep).join('/') } @@ -51,7 +73,7 @@ for (const requiredFile of required) { } } -for (const filename of await walk(root)) { +for (const filename of await publishableFiles()) { const rel = relative(filename) if (ignoredFiles.has(rel)) continue if (forbiddenNames.some((pattern) => pattern.test(path.basename(filename)))) { @@ -59,7 +81,13 @@ for (const filename of await walk(root)) { continue } if (!textExtensions.has(path.extname(filename).toLowerCase())) continue - const data = await readFile(filename) + let data + try { + data = await readFile(filename) + } catch { + // Listed but no longer on disk (e.g. staged deletion) — nothing to scan. + continue + } if (data.includes(0)) continue const text = data.toString('utf8') const lowered = text.toLowerCase()