Compare commits

..

2 Commits

Author SHA1 Message Date
93baba3fa1 console: when an agent stops, say so and offer the way back
The lead page told an operator an automated step "typically completes
within two minutes; this view refreshes automatically" — and went on
saying it indefinitely. Today a lead sat forty minutes under that
sentence while the model provider returned 502s.

Agents stall, and for reasons this app does not control: the provider
slows from 6s a call to 90s or drops the request, a thinking budget runs
out mid-sentence, a late webhook wakes the wrong employee. From the
console every one of those looks identical — a lead that stops — so the
screen now reports the only thing it can honestly know (nothing has
happened for N minutes), says the work so far is safe, and offers the
way out.

THE WAY OUT IS NOT A RETRY BUTTON, because the platform has none and no
agent can be woken directly. But every agent is woken BY AN ACTIVITY, so
performing that activity again wakes it again. nudgeFor() holds that
mapping, and inside Document Pending it picks by how far the chain
actually got — three agents work that state in sequence and the one to
restart is the one that did not finish.

That matters most for the Advisor. It is the only AI step nobody may
perform by hand — permitted to ai_advisor alone — so re-performing the
activity that wakes it is the ONLY route back, and it is the step that
failed twice today. Without this table an operator's only option was to
wait or to call me.

Five minutes before it says anything: an employee wake plus a slow model
is legitimately three or four minutes, and a console that cries stall on
a working lead is one nobody reads.

The clock lives in state, ticking every 20s, rather than Date.now() in
the render body — reading the wall clock while rendering is impure, and
the counter would otherwise only move when something else happened to
re-render the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:37:25 +05:30
5d146e9ffb console: a thread that shows what the customer actually received
The order was right — the audit rows are in sequence and the panel
rendered them in sequence. Two other things were wrong.

A MESSAGE THE CUSTOMER GOT WAS NOT IN THE THREAD. After "Okay I confirm
the policy go ahead" the panel showed nothing from us until their next
question, so it read as though we had ignored somebody agreeing to buy.
We had not — the confirmation went out immediately — but it was composed
inside a trigger node and never written to a field, and the console can
only show what the workflow records. 90 stores it in customer_answer, so
it appears here and in the trail. An operator reviewing an acceptance no
human took needs to see exactly what the customer was told at the moment
they said yes.

ORDERING IS NOW (timestamp, id). One submission writes three rows in the
same second — the trigger commit, its repeat, the settle — so a timestamp
alone left their order to the sort's stability, and a reply could print
before the message it answered whenever the two landed in the same
second. It had not happened yet; it was waiting to.

DE-DUPLICATION ONLY COLLAPSES AN IMMEDIATE REPEAT, not any repeat
anywhere in the thread. A customer who asks the same thing twice because
the first went unanswered has said it twice, and a thread that silently
showed it once would hide exactly the impatience an operator needs to
see. Only a same-side, same-words turn directly after its twin is the
platform talking to itself.

The footer says which sends are still absent and why: the quote (a
registered template, not text we compose) and the read receipt (which
would sit between every question and its answer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:47:49 +05:30
4 changed files with 161 additions and 21 deletions

View File

@ -420,3 +420,71 @@ export function blockedOn(lead) {
}
return null
}
/**
* WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS?
*
* Agents stall. Not often, but they do, and for reasons nothing in this app
* controls: the model provider slows to ninety seconds a call or returns a 502,
* a thinking budget runs out mid-sentence, a late webhook wakes the wrong
* employee. Watched from the console every one of those looks identical
* a lead that simply stops and the screen kept promising it would "complete
* within two minutes" indefinitely.
*
* There is no retry button in the platform, and none of these agents can be
* woken directly. But every one of them is woken BY AN ACTIVITY, so performing
* that activity again wakes it again. That is what this table holds: for a
* lead sitting in an automated step, the activity a person can perform to make
* the stalled agent run once more.
*
* `by` is who to expect to move afterwards, so the button says what will
* happen rather than just what it does.
*
* The choice within Document Pending depends on HOW FAR the chain got, because
* three agents work that state in sequence and the one to restart is the one
* that did not finish.
*/
export function nudgeFor(stage, lead) {
if (!stage || !lead) return null
switch (stage.uid) {
case 'zk-state-new':
return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' }
case 'zk-state-qualified':
return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' }
case 'zk-state-contacted':
return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' }
case 'zk-state-docs': {
// Engage captures, the Advisor recommends, the rating engine prices —
// in that order, each woken by the one before.
const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor'
if (!lead.motor_idv && !lead.sme_value_at_risk) {
return { uid: line, label: 'Capture the risk again', by: 'Engage' }
}
if (!lead.ai_recommended_cover) {
// The Advisor is the ONE step nobody may perform by hand — it is
// permitted to ai_advisor alone. Re-performing the activity that wakes
// it is the only way back, and it is the reason this table exists.
return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' }
}
if (!lead.quoted_premium) {
return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' }
}
return null
}
case 'zk-state-issued':
return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' }
default:
return null
}
}
/** How long an automated step may sit before the console stops reassuring and
* starts offering the way out. Generous: an employee wake plus a slow model
* can legitimately take three or four minutes. */
export const STALL_AFTER_MS = 5 * 60 * 1000

View File

@ -11,18 +11,31 @@ import './Conversation.css'
* right.
*
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
* than left for someone to discover. Every message the workflow RECORDS is
* here: the customer's replies (customer_reply) and the replies written back
* (customer_answer). The three automatic sends are not. The quote goes out as
* a registered WhatsApp template, and the acknowledgement and the acceptance
* confirmation are composed inside trigger nodes and never written to a field.
* A thread that quietly omitted them would be worse than one that says which
* parts it holds.
* than left for someone to discover. The console can only show what the
* workflow RECORDS, so anything sent from inside a trigger node and never
* written to a field is invisible here however certainly the customer received
* it. The acceptance confirmation used to be exactly that the panel showed
* nothing after "I confirm", which read as though we had ignored somebody
* agreeing to buy so 90 writes it into customer_answer and it appears.
*
* Two sends remain absent by choice: the quote, which is a registered WhatsApp
* template rather than text we compose, and the "I have your message" receipt,
* which would put a line between every question and its answer.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
*/
function turnsFrom(rows) {
if (!Array.isArray(rows)) return []
const out = []
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
// Sorted on (timestamp, id). One submission writes three rows in the same
// second the trigger commit, its repeat, and the settle so a timestamp
// alone leaves their order to the sort's stability and puts a reply before
// the message it answers whenever the two land in the same second.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
for (const r of ordered) {
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
@ -35,15 +48,17 @@ function turnsFrom(rows) {
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
}
}
// One submission can be recorded more than once (the trigger commit and the
// settle both carry the payload), so the same sentence would print twice.
// De-duped on side + text rather than on the row id for that reason.
const seen = new Set()
return out.filter((t) => {
const k = t.side + ' ' + t.text
if (seen.has(k)) return false
seen.add(k)
return true
// One submission is recorded three times the trigger commit, its repeat,
// and the settle so the same sentence would print three times.
//
// De-duped against the PREVIOUS turn only, not against the whole thread. A
// customer who asks the same thing twice because the first went unanswered
// has said it twice, and a thread that silently showed it once would hide
// exactly the impatience an operator needs to see. Only an immediate repeat
// of the same side and the same words is the platform talking to itself.
return out.filter((t, i) => {
const prev = out[i - 1]
return !(prev && prev.side === t.side && prev.text === t.text)
})
}
@ -105,9 +120,11 @@ export default function Conversation({ rows, name, onClose }) {
</div>
<p className="conv__note">
Shows the customer&apos;s messages and the replies written back. The quote
itself, the read receipt and the acceptance confirmation are sent
automatically and are not recorded as text, so they do not appear here.
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation. Two automatic sends are not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose, and the &ldquo;I have your message&rdquo; receipt sent
before anything has read it.
</p>
</div>
</div>

View File

@ -7,7 +7,7 @@ import AgentChip from '../components/AgentChip.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
@ -152,6 +152,11 @@ export default function Lead() {
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null)
// A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure React may render twice and get two
// answers and it also means the "nothing for 6 minutes" counter would only
// move when something else happened to re-render the page. This ticks it.
const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => {
if (!quiet) setErr(null)
@ -206,6 +211,11 @@ export default function Lead() {
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
* at all while the tab is in the background.
*/
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id)
}, [])
useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the
// lead has moved on the open form unmounts taking whatever was typed into
@ -268,6 +278,17 @@ export default function Lead() {
// the "in progress" strip below, which would otherwise keep promising that
// something is happening for as long as the lead is left alone.
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
// An automated step that has sat too long. Distinct from `blocked`, which is
// the workflow refusing for a stated reason this is the agent not having
// come back, and the cause is usually outside this app entirely: a slow or
// failing model provider, a thinking budget that ran out mid-sentence, a
// wake that never arrived. The console cannot see any of that, so it reports
// the only thing it can know nothing has happened for a while and offers
// the way out rather than going on promising two minutes.
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null
return (
@ -398,6 +419,29 @@ export default function Lead() {
</button>
</div>
</div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
{stage.by} has not come back. That is usually the model provider being
slow or dropping a request, not a problem with this lead the work so far
is safe and nothing has been lost.
{stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen

View File

@ -1358,3 +1358,14 @@
color: var(--zk-muted);
white-space: nowrap;
}
/* ---- an agent that has not come back ----
Amber like `blocked`, because both need a person but worded and coloured
apart from it: blocked means the workflow refused for a stated reason and
the fix is known; stalled means nobody knows, and the honest offer is to try
again. No pulse either way. */
.doing--stalled {
border-color: var(--zk-amber-line);
background: var(--zk-amber-tint);
}
.doing--stalled strong { color: var(--zk-amber-ink); }