Skip to content

Commit f562e34

Browse files
codeyogi911claude
andauthored
Make assistant support flows survive real customers and real developers (#14)
## Why A live customer conversation on the browser assistant surfaced four compounding failures around one delayed-delivery question: 1. **"My delivery is delayed." never reached the deterministic order intake** — the trigger required the literal word "order", so the model improvised, gluing its own question onto the scripted contact-card line. 2. **A dropped WebSocket erased the flow** — contact details, the pending order lookup, and the conversation history all lived on the connection, and mobile browsers drop sockets constantly. After reconnecting, the customer's order number hit the off-topic guardrail dead end. 3. **A bare order-number reply was not recognized** outside the scripted continuation sentinel. 4. **Store order-name formats containing "/"** (e.g. fiscal-year formats such as `#2026-27/7903`) could not be parsed at all, so lookups were impossible for every order in such a store. There was also no way to test any of this before production, because Turnstile's strict claim verification rejects Cloudflare's documented testing keys — and the `eval:voice` gate turned out to be unrunnable on a fresh clone (workspace-local wrangler path that npm hoists away). ## What - **Durable session state**: contact, verification, pending escalation, and pending order flow move from per-connection state into Durable Object session storage. A dropped socket keeps history and state for a 10-minute grace period and resumes mid-flow on reconnect; a scheduled cleanup wipes everything if nobody returns. - **Broader deterministic intake**: shipment nouns (delivery, package, parcel, shipment) near tracking signals trigger the scripted order flow, not just "order". - **Bare order numbers always enter the order flow**: with contact on file they look up directly; without, they request the contact card once. "/" is accepted in order numbers end to end. - **Local Turnstile test mode**: on the local surface only, a successful siteverify under Cloudflare's documented testing secrets is accepted without claim checks. Non-local surfaces keep strict verification, so a production misconfiguration still fails closed. - **Working eval harness + prompt hardening**: `eval:voice` resolves wrangler through the package; order-result guidance is strengthened (ticket offer is now non-optional). Evals pass **12/13**; the remaining case (an unfamiliar-product how-to that must search before answering) is model-flaky and pre-existing — a deterministic forced-search fix was tried and reverted because it conflicts with the no-tools-on-off-topic bar. Tracked as follow-up. - **Developer-machine hygiene**: the vitest pool pins bindings a local `.dev.vars` would leak into tests, and the public-readiness scan judges what git can publish (`git ls-files`) instead of failing on properly ignored local state. ## Validation - `npm run check` green: 219 tests (including new reconnect-resume, bare-number, trigger-phrase, and testing-secret coverage), all typechecks, generated-file check, and the public scan — with a `.dev.vars` present. - `npm run eval:voice`: 12/13 with the known-flaky case above. - Live WebSocket replay of the original customer scenario against `wrangler dev`: deterministic card on the delivery phrasing, contact survives a simulated connection drop, and the bare order number after reconnect performs a real lookup with a truthful provider-failure reply instead of the off-topic refusal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Morrow Contributors <codeyogi911@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d1dfa78 commit f562e34

10 files changed

Lines changed: 321 additions & 72 deletions

File tree

apps/desk/scripts/run-voice-evals.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { spawn } from 'node:child_process'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join } from 'node:path'
24

35
const port = Number(process.env.VOICE_EVAL_PORT ?? 8794)
46
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) {
5759
}
5860
}
5961

62+
// npm workspaces may hoist wrangler to the repository root, so resolve the
63+
// package instead of assuming a workspace-local node_modules path. The bin
64+
// entry is not in wrangler's export map, so locate it from the package root.
65+
const require = createRequire(import.meta.url)
66+
const wranglerEntry = join(dirname(require.resolve('wrangler/package.json')), 'bin', 'wrangler.js')
67+
6068
const worker = spawn(process.execPath, [
61-
'node_modules/wrangler/bin/wrangler.js',
69+
wranglerEntry,
6270
'dev',
6371
'--config',
6472
'wrangler.voice-eval.jsonc',

apps/desk/src/security/public-write.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ function normalizedHostname(value: string): string {
3030
return value.trim().toLowerCase().replace(/\.$/, '')
3131
}
3232

33+
// Cloudflare's documented Turnstile testing secret keys accept or reject every
34+
// token, but their siteverify responses do not echo the action or hostname
35+
// claims, so the strict claim checks would fail every proof and leave the
36+
// widget flow untestable outside production. On the local development surface
37+
// only, a successful verification under a documented testing secret is
38+
// accepted without claim checks. Non-local surfaces keep strict verification
39+
// even when a testing secret is configured, so a production misconfiguration
40+
// still fails closed.
41+
const TURNSTILE_TESTING_SECRETS = new Set([
42+
'1x0000000000000000000000000000000AA',
43+
'2x0000000000000000000000000000000AA',
44+
'3x0000000000000000000000000000000AA',
45+
])
46+
3347
export async function verifyTurnstileProof(
3448
input: {
3549
token: string | null | undefined
@@ -58,6 +72,7 @@ export async function verifyTurnstileProof(
5872
return { ok: false, reason: 'turnstile_unavailable' }
5973
}
6074
if (!result.success) return { ok: false, reason: result['error-codes']?.[0] ?? 'turnstile_failed' }
75+
if (input.local && TURNSTILE_TESTING_SECRETS.has(env.TURNSTILE_SECRET_KEY)) return { ok: true }
6176
if (result.action !== input.action) return { ok: false, reason: 'turnstile_action_mismatch' }
6277
if (!result.hostname || normalizedHostname(result.hostname) !== normalizedHostname(input.hostname)) {
6378
return { ok: false, reason: 'turnstile_hostname_mismatch' }

apps/desk/src/voice/conversation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function voiceAgentSystemPrompt(
7474
const contact = options.contact !== false
7575
const ordersAvailable = options.orders === true
7676
const orderCapability = ordersAvailable && contact
77-
? `\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.`
77+
? `\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.`
7878
: ordersAvailable
7979
? ''
8080
: `\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
9494
9595
CAPABILITIES
9696
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.
97-
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.
97+
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.
9898
${actionCapability}
9999
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.
100100

0 commit comments

Comments
 (0)