Skip to content

Say on the refusal note what the customer actually got, instead of claiming silence over the copy just sent - #585

Merged
gabrieljablonski merged 4 commits into
mainfrom
fix/nota-de-recusa-diz-que-nao-respondeu
Sep 10, 2026
Merged

Say on the refusal note what the customer actually got, instead of claiming silence over the copy just sent#585
gabrieljablonski merged 4 commits into
mainfrom
fix/nota-de-recusa-diz-que-nao-respondeu

Conversation

@gabrieljablonski

@gabrieljablonski gabrieljablonski commented Sep 10, 2026

Copy link
Copy Markdown
Member

Fixes #584

What was wrong

The contact-auth gate posts a private note to the operator on every refusal. That note said "O agente não respondeu automaticamente" on every refusal, including the ones that had just posted the configured deny message to the customer one line above it.

The operator reads that note in the same conversation, directly below that message. So on the most common refusal the note contradicted what is on the screen, and — worse — it hid the thing whoever picks up the conversation needs: whether the customer already knows they were refused.

What the note should carry

Not the opposite sentence. "O contato foi avisado" is just as useless, for the same reason: the message is visible. A private note earns its space by carrying what is not on screen.

So the note now says nothing about a copy that was delivered (it keeps the endpoint's reason code, which is the part nobody can see), and speaks up in the three cases where nothing reached the customer — each of which is a different action for the operator:

what the customer got note
the deny message (nothing about the copy; reason code only)
nothing, no denyMessage configured Nenhum aviso foi enviado ao contato: não há mensagem de recusa configurada.
nothing, another refusal holds the notice window O aviso de recusa não saiu nesta mensagem: a carência entre avisos já estava tomada por outra recusa.
nothing arrived at all O aviso de recusa NÃO chegou ao contato.

Without the third line, a second refusal inside the window reads as a broken send; without the last one, a copy that never arrived reads as one that did.

The third line says the window was taken, not that a copy landed, and that is not pedantry: single-flight for the authorization call is keyed by contact and request, so two messages from the same person are two deliveries in flight on one conversation. The copy window is claimed before the send, so the delivery that loses the claim runs on, takes the note window, and writes its note while the winner is still awaiting Chatwoot. If the winner's send then fails, it hands the window back — and the only note the operator got would have claimed a delivery that never happened.

That last line reports the result and names no cause on purpose. postPublicMessage returns the same false for two different things: the send threw, and the ownership fence stood the copy down because a human took the conversation inside the authorization round-trip (or the agent was switched off in that window). The runtime cannot tell them apart, and the note has no fence of its own — it is written for the person who just took the conversation over. Saying "the send failed" there would send them chasing a problem that does not exist.

The call site already knew which of the four applied — it decides whether there is a denyMessage, whether the cooldown let it through, and whether the send landed. The knowledge simply never reached the note. no_identity and error are unchanged: they are silent to the customer by design, there is no copy to describe, and the old sentence was true there. The new parameter defaults to none, so any caller that sends no copy stays correct.

Validation scope

Proved by test

  • Red first: with src/modules/chatwoot/webhook.ts reverted to the base (f1194d4d) and the new tests in place, 6 of 20 fail — the five note-text cases plus the e2e denied assertion. With the fix, 22/22 pass (bun test tests/modules/contact-auth-note-copy.test.ts tests/modules/contact-auth-gate-e2e.test.ts).
  • 7 unit tests on contactAuthNoteText, one of which pins the sent note as the shortest of the four, so nobody restores a redundant sentence.
  • 7 end-to-end paths through the gate with the webhook, the DB and a Chatwoot double: copy delivered, no copy configured, send failed, repeat withheld by the cooldown, the copy stood down by the ownership fence, two refusals racing for the copy window where the winner's send fails last, and the delivery after a failed send, which pins that the claimed window was handed back rather than silencing the next refusal for the whole 300s over a message nobody received. The middle two are reachable only with injected delivery failures, which the stub now supports.
  • A discrimination test that does not depend on the words chosen: with the same reason code and the same handoff, the four states must produce four different notes, each carrying the reason code and none carrying the contact's phone. Byte-identical notes were the actual defect, so this pins it independently of any wording.
  • Mutation battery, 10 mutants over both the wording and the call-site mapping, zero survivors. Two survived the first pass — inverting failed/suppressed, and pinning the outcome to none — which is exactly why the two new e2e paths were added; both die now.
  • bun biome check clean on the three touched files, bunx tsc --noEmit clean.

Proved live

Against a real Chatwoot (a local fork on 127.0.0.1:10997, account 5, API inbox 11) with the gate driven through processChatwootDelivery and a real ChatwootClient, so the note is rendered by Chatwoot itself and read back over its API. Both halves, side by side:

  • conversation 33, webhook.ts reverted to the base: the deny copy (msg 197) and, right below it, the note claiming the agent stayed silent (msg 198);
  • conversation 37, this branch at its final head: same refusal, same copy delivered (msg 201), and the note (msg 202) no longer talks about the copy;
  • conversation 38, an agent with no denyMessage: nothing to the customer, and the note (msg 203) says so and why;
  • conversation 39, a human taking the conversation over during the authorization call: the fence stands the copy down, no handoff, and the note (msg 204) reports that nothing reached the contact without inventing a delivery failure.

Not proved here

  • The full suite has one failure unrelated to this change, tests/graph/tools-http-body-bound.test.ts (a memory-pressure test), which fails identically on a clean checkout of the base on this machine.
  • The live run above used a local Chatwoot, not the deployment where the defect was reported: seeing the fix there would mean deploying to it, which is a production change and out of scope for this PR.
  • The suppressed state (an earlier note that failed frees its own window while the copy's stays spent) is exercised in the test suite, not in the live run.

🤖 Generated with Claude Code

…aiming silence over the copy just sent

The operator note said "O agente não respondeu automaticamente" on EVERY contact-auth refusal, including the ones that had just delivered the deny message to the customer. The operator reads that note in the same conversation, one line below that very message: the sentence contradicts the screen, and it hides from whoever picks the conversation up that the customer already knows.

The fix is not to announce the opposite. "O contato foi avisado" is just as useless, for the same reason: the message is right there. The note earns its space by carrying what is NOT on screen. So when the copy went out, the note stays quiet about it and keeps only the reason code, which is the part nobody can see. The three cases where NOTHING reached the customer are the invisible ones, and each is a different thing to do:

- nobody configured a deny message;
- the notice cooldown withheld the repeat (without this, a second refusal inside the window looks like a broken send);
- the send failed, which is a delivery problem to chase.

The call site already knew which of the four it was: it decides whether there is a `denyMessage`, whether the cooldown let it through, and whether the send landed. That knowledge just never reached the note. `no_identity` and `error` keep their wording: they are silent to the customer by design, there is no copy to describe, and the old sentence was true there. The new argument defaults to `none`, so a caller that sends no copy stays correct.

Coverage: 7 unit tests on the note text plus 4 end-to-end paths through the gate (copy delivered, no copy configured, send failed, repeat withheld by the cooldown). A 10-mutant battery over both the wording and the call-site mapping leaves no survivors; the two that first survived — inverting `failed`/`suppressed`, and pinning the outcome to `none` — are what the two new e2e paths exist to kill. One test pins the `sent` note as the SHORTEST of the four, so nobody "improves" it back into a redundant sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fazer-ai-bot
fazer-ai-bot previously approved these changes Sep 10, 2026

@fazer-ai-bot fazer-ai-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Codex Review

The change accurately tracks refusal-message outcomes and incorporates them into operator notes without changing delivery or cooldown behavior. All seven new unit tests passed; database-backed integration tests were not run.

No actionable findings in this pass.

Reviewed commit: 108a69caf9 · gpt-6-astra · effort high

… a failed send from a fence

The holdout scenarios for this issue named a case the first pass got wrong. `postPublicMessage` returns `false` for two different things: the send threw, and the ownership fence stood the copy down because a human took the conversation inside the authorization round-trip (or the agent was switched off in that window). The call site sees one boolean. Wording that says "o envio NÃO foi concluído" reads as a delivery failure, and on the takeover it sends the operator chasing a problem that does not exist — on the very conversation they just picked up, since the note has no fence of its own and is written FOR them.

So that line now reports the result and no cause: "O aviso de recusa NÃO chegou ao contato." The other two invisible states keep naming their cause, because there the runtime knows it: no `denyMessage` configured, and the cooldown withholding a repeat.

Also from the holdout: a discrimination test that does not depend on the words chosen — with the same reason code and the same handoff, the four states must produce four different notes, all carrying the reason code and none carrying the contact's phone. Byte-identical notes were the actual defect, and this pins it independently of any wording. Plus the e2e for the fence case, and the `denied` bullet in docs/contact-auth.md, which described the note as one fixed sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@fazer-ai-bot fazer-ai-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Codex Review

The sequential paths are covered and the eight new unit tests pass, but concurrent deliveries can produce an incorrect cooldown explanation that suppresses the actual failure note.

Reviewed commit: 2b69e8f116 · gpt-6-astra · effort high

Comment on lines +4114 to +4118
let copyOutcome: ContactAuthCopyOutcome = denyMessage
? copyClaim
? "failed"
: "suppressed"
: "none";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for pending copy attempts before reporting suppression

With concurrent refused deliveries for the same conversation, a failed copy claim can mean another send is still pending, not that a notice was already delivered. For example, with handoff disabled, delivery B can post the new cooldown note while delivery A awaits postPublicMessage. If A then fails, B has already consumed the note window, so the only operator note incorrectly describes a withheld repeat rather than an undelivered notice. Serialize the notification sequence per conversation or await the pending copy's outcome before choosing and posting the note.

codex · gpt-6-astra · effort high · confidence 0.96

… a racing refusal may still fail

Review found a case the cooldown wording asserted and could not know. Single-flight for the authorization call is keyed by contact AND request, so two messages from the same person are two questions and two deliveries in flight at once on one conversation. The copy window is claimed BEFORE the send, so the delivery that loses the claim proceeds immediately, takes the note window, and writes its note while the winner is still awaiting Chatwoot. If the winner's send then fails it hands the copy window back — and the only note the operator got says a notice was already delivered, on a conversation where nothing ever reached the customer.

The note cannot know: the loser sees a failed claim, and nothing more. So it now reports what is true in both branches — this message produced no notice because another refusal holds the window — and never that a copy was repeated or delivered. Whether one is on screen is the operator's to see; what they cannot see is that THIS message produced none, and why.

The e2e for it parks the first delivery inside Chatwoot's send (a latch in the double), runs the second to completion against the held window, and only then fails the first. It fails against the old wording, as does the unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fazer-ai-bot
fazer-ai-bot previously approved these changes Sep 10, 2026

@fazer-ai-bot fazer-ai-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Codex Review

The change accurately carries the refusal-copy outcome into operator notes while preserving existing cooldown, handoff, and ownership behavior. All eight focused unit tests passed; database-backed integration tests were not run.

No actionable findings in this pass.

Reviewed commit: e6d213a168 · gpt-6-astra · effort high

…ing exercised end to end

The scenario run found three gaps in the tests this PR adds, all of them things I had checked by hand and left unpinned. The one that matters: `releaseContactAuthNotice(copyClaim)` hands the copy window back when the send does not land, and no end-to-end test made a second delivery afterwards. Without it, one message the customer never received would silence the next refusal for the whole 300s window — the exact failure the release exists to prevent, and the suite would not have noticed it going away. The stub now fails only the FIRST send, so the delivery after it can speak, and the test asserts the deny copy goes out as the persona. Removing the release makes it fail.

The other two: the cooldown test counted the copies without checking what they were, so it now asserts content and token like the others; and the new scenarios asked for a refusal with no reason code, which left "the reason survives in all four states, and the phone never leaks" asserted end to end only in the delivered-copy case. They now carry `not_customer` and assert both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@fazer-ai-bot fazer-ai-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Codex Review

The change correctly tracks refusal-message outcomes and uses them in operator notes without altering delivery, cooldown, or handoff behavior. All eight new unit tests passed; database-backed integration tests were not run.

No actionable findings in this pass.

Reviewed commit: 80ac1d9e23 · gpt-6-astra · effort high

@fazer-ai-bot

Copy link
Copy Markdown
Collaborator

ship gate · tier=T2 · dod=PASS · head=80ac1d9
flags: auth docs integration_strong tests · code=59 · auth ou permissões
evidência: 2 teste(s) (red 6→0), mutação 12/0, e2e Chatwoot (fork local, http://127.0.0.1:10997, conta 5, caixa API 11) com o gate rodando pelo processChatwootDelivery e um ChatwootClient REAL, nao duble (5 evidências), verificador 10/0, review codex-review-585-r4
decisões sem consulta: None (A mensagem de recusa esta uma linha acima na mesma conversa. Anunciar que ela saiu gasta a nota com o que o operador ja ve; a nota se paga carregando o que NAO esta na tela. Um teste prende a nota de sent como a mais curta das quatro para que ninguem a "melhore" de volta.); None (Sao silenciosos ao cliente por desenho: nao ha copia para descrever, e ali "o agente nao respondeu automaticamente" e verdade.); None (So existe um chamador de producao. O default mantem correto qualquer chamador que nao envia copia, e o teste do default prende isso.); None (O fix nasceu de um teste manual em producao com o usuario, horas antes de a skill /ship ser invocada; a issue e a PR sao posteriores ao commit. Os cenarios do verificador foram escritos sem ver o diff, mas nao podem ser chamados de holdout no sentido estrito. Registrado aqui em vez de escondido.); None (O defeito foi observado em producao (foi de la que a issue nasceu), mas ver o CONSERTO ao vivo exigiria fazer deploy do codigo novo na instancia do cliente, que e mutacao de producao e nao foi pedida. O Chatwoot local e o mesmo software e renderiza a mesma nota, e as duas metades (defeito e conserto) foram exercitadas nele, lado a lado.); None (Os cenarios holdout mostraram que postPublicMessage devolve o mesmo false para envio que estourou e para a cerca de posse que derrubou a copia (humano assumiu a conversa, ou o agente foi desligado). A nota nao tem cerca e e escrita PARA quem assumiu: afirmar falha de entrega ali manda o operador perseguir um problema inexistente.); None (O defeito e a nota afirmar um fato que o runtime nao tem: quem perde a disputa pela janela so sabe que a janela esta tomada. Serializar por conversa e mudanca de comportamento com risco proprio (latencia e travamento) e fora do escopo da issue, que e sobre o que a nota diz. A nota passa a dizer o que e verdade nos dois ramos, e um e2e com as duas entregas em voo prende isso.); None (O invariante (handoff fica FORA da carencia, e cada recusa fresca tenta de novo) ja e fixado pelo teste antigo da carencia, que nao depende de falha nenhuma. O verificador conferiu ad hoc neste HEAD e bate. Uma linha a mais custaria outra rodada de review e outra reemissao do relatorio, sem prender invariante novo.)

@gabrieljablonski gabrieljablonski added the needs-human:risk parqueada pelo ship gate: precisa de decisão humana label Sep 10, 2026
@gabrieljablonski
gabrieljablonski merged commit 3e7487d into main Sep 10, 2026
6 checks passed
@gabrieljablonski
gabrieljablonski deleted the fix/nota-de-recusa-diz-que-nao-respondeu branch September 10, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human:risk parqueada pelo ship gate: precisa de decisão humana

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contact-auth refusal note claims the agent stayed silent right after delivering the refusal

2 participants