Sete fases e onze testes: como a tela do agente aguenta queda, pausa e falta de rede
Juntei streaming, timeout, retry e offline numa máquina de estados tipada de sete fases, com um reducer puro e onze testes que rodam sem navegador. O fechamento da série Frontend com IA, e como eu levaria o mesmo desenho para as ações de um agente com aprovação humana.
A tela de um produto de IA quebra quando cada falha vira um if solto. Eu juntei streaming, timeout, retry e falta de rede numa máquina de estados de sete fases, e os casos difíceis passaram a caber em onze testes que rodam sem abrir o navegador.
Esse é o quarto e último item da série Frontend com IA. Os três primeiros olharam uma peça de cada vez: o stream que cai no meio, a main thread que trava e o modelo rodando no navegador. Aqui a pergunta é outra: o que acontece quando tudo isso falha junto, na mesma sessão.
O que a pessoa vê
A demo é a pasta 04-resilient-ui, em TypeScript estrito. A página mostra uma resposta chegando em streaming e deixa escolher três cenários: normal, queda no meio e pausa longa. Este link já abre no cenário de queda, em que a conexão é cortada no décimo token e retomada.
A tela compara dois painéis: “Sem tratar a falha” e “Com a máquina”. A fase atual fica escrita, junto com um log das transições, algo como idle -> connecting -> streaming. Dá para desligar a rede no meio, segurar uma pausa maior que o timeout ou cancelar.
O painel sem tratamento descreve o que a maioria das interfaces faria. No cenário normal, o texto chegaria inteiro. Mas no cenário de queda, o texto pararia no token 10, pela metade, e na pausa longa a leitura travaria. O painel da máquina retoma e diz em que tentativa terminou. Quando não consegue, para em failed e mostra o motivo, em vez de fingir que o texto chegou.
Por que acontece
O problema é esse: a interface tem muitos estados, mas o código só declara alguns. O resto aparece da combinação de flags. isLoading verdadeiro com error preenchido e isOffline falso é um estado. Ninguém deu nome a ele, ninguém testou, e ele acontece em produção.
Eu já escrevi tela assim. Cada bug novo ganhava um if a mais, e cada if corrigia um caso e abria outro. Achava que era falta de cuidado. Mas era falta de desenho: sem uma lista fechada de estados, não existe como saber se cobri todos.
As sete fases
A máquina está em src/machine.ts. As fases são uma lista fechada:
export const PHASES = [
'idle',
'connecting',
'streaming',
'stalled',
'retrying',
'failed',
'done',
] as const; E os eventos também. São treze, todos tipados: start, connected, token, stall, disconnect, timeout, offline, online, retry, cancel, complete, flush e fail. Qualquer coisa que aconteça na tela entra por um desses nomes.
O coração é uma função pura. Recebe o estado atual e um evento, devolve o próximo estado:
case 'disconnect':
case 'timeout':
if (!isTransfer(state.phase)) return state;
return failOrRetry(state, event.type === 'timeout' ? 'Tempo esgotado.' : 'Conexão caiu.'); Veja a primeira linha do caso. Se a sessão não está transferindo dados, um disconnect atrasado é ignorado. Esse é o tipo de regra que, espalhada em if, ninguém lembra de escrever. Um token que chega depois do cancelamento, um timeout que dispara depois do fim: tudo cai no return state e a tela não muda.
failOrRetry decide entre tentar de novo e desistir. A sessão começa com três tentativas no máximo. Enquanto sobra tentativa, a fase vai para retrying e o texto parcial fica na tela. Na última, vai para failed com o motivo escrito.
O que mora fora da máquina
O reducer não sabe de rede, relógio nem DOM. Quem liga tudo isso é src/runner.ts, e os números estão lá como constantes:
const CONNECT_TIMEOUT_MS = 4_000;
const STALL_TIMEOUT_MS = 1_200;
const BACKOFF_BASE_MS = 350;
const BACKOFF_CAP_MS = 2_000;
const FLUSH_PER_FRAME = 4; Quatro segundos para conectar. Se o stream fica mais de 1,2 s sem token, isso conta como timeout. O backoff dobra a partir de 350 ms e para em 2 s. Cada pedaço do runner só faz uma coisa: arma um timer, escuta online e offline na window, cancela a leitura com AbortController e traduz o que viu num evento para a máquina.
Tem uma decisão aqui que eu demorei para aceitar. Quando o navegador avisa que ficou offline no meio do stream, a máquina vai direto para failed, sem retry. Tentar de novo sem rede só gasta tentativa. E o MDN avisa que navigator.onLine verdadeiro não garante acesso à internet, então o sinal de offline é confiável e o de online nem tanto. Por isso o evento online só limpa a flag. Quem decide recomeçar é a pessoa.
Outra é o backpressure. A máquina guarda os tokens que chegaram e ainda não foram pintados. Quando a fila passa de 16, a leitura pausa. Quando a tela drena até 6, volta a ler. A pintura acontece no requestAnimationFrame, quatro tokens por frame. É como a fila do caixa do mercado: quando o corredor enche, alguém fecha a porta por um instante, senão ninguém anda.
Os testes que isso compra
Como o reducer é puro, testar uma falha é escrever uma lista de eventos e conferir a fase no fim. Este é um dos casos de src/machine.test.ts:
it('preserva o texto parcial no retry automático', () => {
let state = createSession();
state = apply(state, [
{ type: 'start' },
{ type: 'connected' },
{ type: 'token', text: 'parcial' },
{ type: 'flush', count: 1 },
{ type: 'disconnect' },
{ type: 'retry' },
]);
expect(state.phase).toBe('connecting');
expect(state.text).toBe('parcial');
expect(state.received).toBe(1);
}); O arquivo tem onze casos: dez de transição e um do backoff. Eles cobrem queda, timeout na conexão, offline no meio do stream, retry esgotado, cancelamento ignorando token fora de hora e a pausa por backpressure. Nenhum precisa de navegador, servidor ou timer falso. O Playwright fica só com o teste de ponta a ponta, que confirma que a máquina conversa com o servidor de mentira.
Pera. Teste verde aqui prova uma coisa só: as regras estão escritas num lugar e se comportam como escritas. Se a regra estiver errada, o teste vai passar com a regra errada. Mas pelo menos dá para ler a regra e discordar dela.
Onde isso vira plataforma de IA
Até aqui é uma resposta de chat. Num produto com agentes, a parte que mais me preocupa é outra: a ação que o agente quer executar. Abrir um PR, mexer num ticket, rodar um comando via MCP.
Eu ainda não construí essa parte, mas acho que ela pede o mesmo desenho. Fases fechadas, algo como pendente, aprovado, executando, falhou e desfeito, e eventos pequenos entre elas. A regra que eu colocaria primeiro é negar por padrão: nenhuma transição leva a executando sem passar por aprovado. Num gateway de MCP, isso é a mesma política de permissão das ferramentas, só que visível na tela.
Três coisas que aprendi nessa demo eu levaria direto:
- Timeout e retry são eventos da máquina, e a tela mostra em que tentativa está. A pessoa que aprova precisa saber se a ação já rodou uma vez.
- Retry automático só para ação idempotente. Na resposta de chat, retomar do token 10 é seguro. Numa ação que cria algo, o backend precisa de uma chave de idempotência, senão o retry cria duas vezes.
- A aprovação fica presa ao que a pessoa viu. Escrevi sobre isso no post de MCP Tasks e o commit que a pessoa aprovou. Se o conteúdo mudou depois, a máquina volta para
pendente.
Depende do produto, claro. Uma tela interna com três usuários talvez não precise de nada disso. Mas quando o agente mexe em coisa de verdade, eu prefiro que o estado mais perigoso seja um nome que o teste consegue verificar.
O que eu faria amanhã
Pegue a tela de IA que mais dá suporte no seu produto e escreva, num papel, todas as fases que ela pode ter. Depois procure no código onde cada uma está declarada. Se alguma só existe como combinação de flags, comece por ela.
A demo está no ar, o código está no repositório frontend-ia-demos, e as quatro demos da série estão em eliseu.dev/projects. Qual fase a sua interface tem e ninguém deu nome?
Fontes
- Código da demo 04-resilient-ui (
src/machine.ts,src/runner.ts,src/machine.test.ts), repositório frontend-ia-demos - Demo no ar no cenário de queda
- Navigator.onLine, MDN
- AbortController, MDN
- Conceitos da Streams API, incluindo backpressure, MDN