---
title: "Sete fases e onze testes: como a tela do agente aguenta queda, pausa e falta de rede"
slug: ui-resiliente-maquina-de-estados-agente
date: 2026-11-04
category: "Arquitetura"
tags: ["ia", "agentes", "typescript", "frontend", "arquitetura", "llm"]
readTime: "7 min"
excerpt: "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."
url: https://eliseu.dev/blog/ui-resiliente-maquina-de-estados-agente
project: resilient-ui
---

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](/blog/streaming-ui-llm-queda-de-rede), [a main thread que trava](/blog/main-thread-gargalo-produto-ia) e [o modelo rodando no navegador](/blog/rag-no-navegador-webgpu-quando-vale). 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](https://github.com/EliseuSantos/frontend-ia-demos/tree/main/demos/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](https://demos.eliseu.dev/frontend-ia-demos/04-resilient-ui/?cenario=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:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
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:

1. 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.
2. 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.
3. 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](/blog/mcp-tasks-aprovacao-commit). 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](https://demos.eliseu.dev/frontend-ia-demos/04-resilient-ui/?cenario=queda), o código está no [repositório frontend-ia-demos](https://github.com/EliseuSantos/frontend-ia-demos), e as quatro demos da série estão em [eliseu.dev/projects](https://eliseu.dev/projects). Qual fase a sua interface tem e ninguém deu nome?

## Fontes

- [Código da demo 04-resilient-ui](https://github.com/EliseuSantos/frontend-ia-demos/tree/main/demos/04-resilient-ui) (`src/machine.ts`, `src/runner.ts`, `src/machine.test.ts`), repositório [frontend-ia-demos](https://github.com/EliseuSantos/frontend-ia-demos)
- [Demo no ar no cenário de queda](https://demos.eliseu.dev/frontend-ia-demos/04-resilient-ui/?cenario=queda)
- [Navigator.onLine](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine), MDN
- [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), MDN
- [Conceitos da Streams API, incluindo backpressure](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts), MDN