As quatro confusões relatadas na tela do Inbox têm três causas medidas, e a
principal não é de interface:
1. `lib/agent-engine/` NUNCA lê `assignee_kind` nem `assigned_to_user_id`
(`grep -rn` → rc=1) e `fn_conversation_assign` nunca tocou
`bot_silenced_until`. Um atendente clicava "Assumir" e o atendimento
automático continuava respondendo o MESMO cliente — ele só calava por 5
minutos deslizantes quando a pessoa ENVIAVA (`extendBotSilence`). O guard
existe desde a decisão G3-02 e só o worker LEGADO o implementa
(`skip("assigned_to_human")`); o motor moderno regrediu sem ninguém notar. É
por isso que nenhum selo de "você está no comando" podia ser verdade.
2. Só existia a VOLTA (`reactivate-bot`). Nenhuma rota, nenhum botão desligava o
automático: um interruptor com um lado só.
3. A tool MCP `crm_list_conversations` resolvia `assigned_to_user_name` e a rota
REST da MESMA listagem devolvia só o UUID — a IA sabia o nome de quem estava
atendendo e a tela não.
O conserto de (1) entra na função de atribuição e NÃO no motor, porque
`bot_silenced_until` é o gate que o motor já lê. A alternativa óbvia — ensinar o
motor a ler `assignee_kind` — foi medida em pg17 e REPROVADA: `Fechar` não solta
o dono, de propósito, então o fim NORMAL de um atendimento deixaria
`assignee_kind='user'` pendurado e, como aquele gate é por CONTATO, calaria o
automático para sempre naquele cliente, inclusive em conversa NOVA de outro
número. Por isso também `close` passa a limpar o silêncio: a ingestão reusa a
MESMA linha de conversa, e sem isso a pausa vazaria para o próximo episódio.
O rodízio (`reason='routing'`) NÃO cala: distribuir não é assumir, e
`trg_conversation_routing_requested` dispara em toda conversa nova com o worker
rodando 1×/min — sem a ressalva, uma org em round_robin ficaria sem automático
na primeira mensagem da vida de cada cliente, numa tela de configuração que não
menciona IA.
Na tela, `lib/inbox/comando-da-conversa.ts` passa a ser a fonte única de "quem
manda" e "o automático está ativo?", espelhando os gates do motor. Eram três
leituras parciais do mesmo estado: o cabeçalho lia duas travas, a linha da lista
lia uma (por COR, e por um proxy ruim — `status='ai_handling'`, cujo único
escritor em produção é o botão de devolver, então o ícone de robô aparecia só em
conversa que já tinha sido escalada E devolvida), e o painel não lia nenhuma.
Reusa `OwnerBadge` (disco cheio = pessoa, anel vazado = automático) em vez de
inventar um terceiro jeito de dizer a mesma coisa por cor; a variante compacta é
prop no MESMO componente. Na lista o badge só aparece onde DISCRIMINA — nas abas
Fila/Minhas/IA ele seria a mesma palavra em toda linha.
De brinde, um furo de RLS que a mesma medição achou: `cae_select` era membership
de org PURA enquanto `conversations_select` é visibility-aware — um agent em modo
`own` lia 0 conversas do colega e 1 linha da auditoria de atribuição da mesma
conversa, alcançável pelo PostgREST com a anon key. A policy passa a herdar o
escopo no molde do `messages_select`.
E a troca de comando entra na linha do tempo que JÁ existe (4 tipos novos em
`ACTIVITY_LABELS`) em vez de ganhar tabela e seção próprias: a ida e a volta
IA↔humano já eram emitidas e já apareciam naquele painel, então uma segunda
lista ao lado seria dois lugares contando a mesma história. O motivo que a pessoa
escreve ao transferir passa a chegar à tela — hoje ele morre no `metadata` do
audit, que só `admin` lê.
DESKCOMM_GOV_INVARIANTS_EDIT=1 — e a razão, porque a válvula não é passe livre:
o congelamento supõe "invariante incômodo = código errado ou invariante
mal-escrito". Aqui é o terceiro caso: o COMPORTAMENTO mudou de propósito, e os
dois arquivos codificavam o vazamento que esta migration fecha.
`gov-3-assignment-events` contava os eventos como o agent que ACABOU de
transferir a conversa — leitura que agora devolve 0, corretamente. Ele não foi
afrouxado: passa a contar como manager (quem tem escopo), ganha um caso NOVO que
prova o escopo herdado com controle positivo e negativo, e o caso de append-only
passou a escrever como manager de propósito — escrevendo como o agent sem escopo,
o `0` esperado viria de invisibilidade e não da ausência de policy de UPDATE, e a
catraca ficaria satisfeita pelo motivo errado. `gov-5-visibility-scope` ganha
três casos, no molde do que ele já faz para `messages`.
Evidência: `pnpm test:db` exit 0 (install + update ok, 113 arquivos, 851 testes);
typecheck zerado; `eslint` sem erro nos arquivos tocados; 17 casos novos em
`lib/inbox/comando-da-conversa.test.ts`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbZZsyZHauf2XuMxQTWGyv
8.2 KiB
Inbox: quem manda nesta conversa
Base origin/main @ 927dfa51 · worktree /Users/rafaelmelgaco/wt/inbox-comando · branch feat/inbox-quem-manda.
Este arquivo é a segunda versão. A primeira foi submetida a cinco críticos que mediram em Postgres real; a lente de regressão devolveu PARA e duas decisões morreram. O que segue já incorpora os consertos — as decisões mortas estão no fim, com a medição que as matou, para ninguém as ressuscitar.
As 4 reclamações e a causa medida
| # | Reclamação | Causa-raiz MEDIDA |
|---|---|---|
| 1 | Não se sabe quem está no controle | Ninguém está. fn_conversation_assign nunca toca bot_silenced_until, e o motor moderno nunca lê assignee_kind (grep -rn "assignee_kind|assigned_to_user_id" lib/agent-engine/ → rc=1). Um humano dá "Assumir" e a IA continua respondendo; ela só cala 5 min deslizantes quando o atendente envia (extendBotSilence, app/api/v1/messages/_handler.ts:248). |
| 2 | Não se sabe se a IA está ativa, nem liga/desliga | Só existe o botão de LIGAR (reactivate-bot). Não há rota nem botão de DESLIGAR. |
| 3 | Não há badge do atendente | O payload devolve assigned_to_user_id cru. A tool MCP resolve assigned_to_user_name pelo MESMO handler (lib/mcp/tools/conversations.ts:74): a IA sabe o nome de quem atende; a tela não. components/kanban/OwnerBadge.tsx (humano vs. IA por geometria, com teste) nunca saiu de components/kanban/. |
| 4 | As atividades não mostram transferências | claim/transfer/release não emitem atividade nenhuma (grep nas três rotas: zero). A ida e a volta IA↔humano já emitem (handoff_triggered/handoff_resolved) e já aparecem no painel — só a troca entre humanos é muda. |
O conceito único: comandoDaConversa()
lib/inbox/comando-da-conversa.ts — função pura, sem I/O, três consumidores reais (linha da lista,
cabeçalho, painel). Responde as duas perguntas com UMA verdade, lendo só colunas que a linha já carrega:
Comando = { quem: "humano", userId, nome } | { quem: "automatico" } | { quem: "aguardando" } | { quem: "encerrada" }
MotivoDoSilencio = "atendente_no_comando" | "contato_travado" | "pausado" | "resposta_humana_recente"
Quatro motivos, não seis: janela_fechada saiu porque o canal primário (WAHA) não tem janela de 24h
(lib/channels/capabilities.ts:15 freeformOutsideWindow: true) e quem já responde por isso na tela é
o JanelaSelo, provider-aware; encerrada saiu porque STATUS_LABEL já a mostra. Motivo que não muda
a ação de ninguém é telemetria, não informação (invariante 5).
Decisões
R1 — "Assumir"/"Transferir" calam o automático; "Liberar"/"Fechar" o devolvem.
fn_conversation_assign (create or replace, mesma assinatura de 6 args — assinatura nova criaria
overload e as 5 chamadas por nome passariam a falhar com is not unique, medido) passa a gravar
bot_silenced_until. Três braços:
p_reason = 'routing'→ não mexe. O rodízio distribui, não toma o comando. Sem esta ressalva, uma org emround_robin(worker de 1 em 1 min sobre um trigger que dispara em TODA conversa nova) teria a IA calada na primeira mensagem da vida de cada cliente — medido — e a tela que liga o rodízio não diz uma palavra sobre IA.- destino humano (
claim/transfer) →'infinity'. - destino nulo (
release) →null. Ecloselimpa junto: sem isso o silêncio vaza para o PRÓXIMO episódio, porque a ingestão reusa a MESMA linha de conversa (on conflict do update) eFecharnão solta o dono, de propósito.
Usa o gate que o motor já lê — nenhuma linha do motor muda. É por isso que não mexemos em
assignee_kind: ele nunca é limpo por close/release, então viraria mudez permanente por pessoa.
R2 — o liga/desliga na tela. POST /api/v1/conversations/[id]/pause-ai: silencia e, se ninguém for
dono, assume para quem clicou — pausar sem dono deixaria a conversa sem ator nenhum, que é morte por
definição (invariante 4). Par simétrico do reactivate-bot que já existe. Um botão no mesmo slot com
dois rótulos ("Pausar o automático" ⇄ "Devolver ao automático"): dois botões custam 85px numa barra que
já estourou a caixa de 392px em 1280px uma vez. E o "Devolver ao automático" passa a aparecer também em
closed/archived — hoje uma conversa fechada com o automático parado não tem NENHUMA porta de volta
para quem não é o dono.
R3 — o nome de quem atende chega à tela. assigned_to_user_name no payload (list + get). O lookup usa
createAdminClient() só para o nome — o client dos DADOS não muda, senão o service role bypassa
conversations_select e desliga o visibility_mode inteiro, em silêncio. Sem service role, o campo vem
null declarado (como team/assignable faz), nunca por acidente: resolveUserNames com o client da
rota devolve null para todos calada (GoTrue responde 403 not_admin e o supabase-js não lança).
Decisão registrada: quem enxerga a conversa passa a saber quem a atende — inclusive viewer. Alarga de
propósito a matriz spec 13 §4, pela mesma razão da exceção que team/assignable já abriu: não dá para
mostrar a conversa a alguém e esconder de quem ela é.
OwnerBadge é reusado (geometria, nunca cor) no cabeçalho sempre, e na lista só quando discrimina —
mostrarAtendente só nas abas onde pode haver mais de um dono, na mesma regra do badge de canal.
E a Fila passa a incluir pending: hoje a conversa que o automático escalou não aparece em aba
nenhuma que o agent enxerga.
R4 — a troca de comando entra na linha do tempo que JÁ existe. Quatro tipos novos em
ACTIVITY_LABELS (Record<ActivityType,string> exaustivo, o compilador cobra) emitidos por
claim/transfer/release/pause. Sem tabela nova, sem rota nova, sem segunda linha do tempo: a ida e a
volta IA↔humano já vivem ali e uma seção "Quem atendeu" ao lado seria dois lugares contando a mesma
história. O motivo que a pessoa escreve ao transferir passa a ir no reason da atividade — hoje ele
morre no metadata do audit, que só admin lê.
R5 — o furo de RLS que a medição achou de brinde. cae_select é org-flat enquanto
conversations_select é visibility-aware: um agent em modo own lê 0 conversas do colega e 1 linha
de conversation_assignment_events da mesma conversa (medido). A policy passa a herdar o escopo, no molde
de messages_select, e ganha caso em gov-5-visibility-scope.test.ts.
Léxico
A palavra do estado é "automático", não "IA" — já é contrato em 4 arquivos e está travada por
tests/unit/handoff-por-orcamento.test.ts, cujo controle NEGATIVO usa literalmente "Voltar para a IA"
como a sabotagem que deve reprovar. Rótulos novos: "Assumiu a conversa", "Transferiu a conversa",
"Liberou a conversa", "Pausou o automático". Nenhum verbo nu, nenhum termina em preposição.
Todo rótulo novo passado por t() ganha entrada em lib/i18n/dicionario.ts no mesmo commit.
Decisões MORTAS (não ressuscitar)
— o gate é por CONTATO eor v.assignee_kind='user'emisLeadInHandoffFecharnão solta o dono: medido em pg17, o fim normal de um atendimento (Assumir→Fechar) deixava a IA muda para sempre naquele contato, inclusive em conversa NOVA de outro número.— a premissa era falsa (as duas transições que importam já geram atividade visível);conversation_assignment_eventsganhafrom_kind/to_kind/to_agent_id/note+ rota de leituranotelevaria texto livre sobre cliente de uma tabela que sóadminlê para uma que todo membro lê, fora da cascata LGPD e sem retenção; e a FK do agente quebravaDELETEde agente com 23514 numa tabela append-only.—p_noteemfn_conversation_assigncreate or replacenão substitui assinatura: vira overload e as 5 chamadas por nome falham com is not unique (medido).
DoD específico
Migration versionada + apêndice idempotente no baseline.sql + linha no MANIFEST · pnpm test:db local ·
E2E pela TELA em banco fresco (DoD 12) · rótulo novo em dicionario.ts · revoke ... from public, anon
nas duas origens em qualquer função tocada · mapa escalacao-ciclo-humano.architecture.json atualizado.