diff --git a/docs/plans/e2e-dev-router-quiescencia.md b/docs/plans/e2e-dev-router-quiescencia.md new file mode 100644 index 00000000..5a06a791 --- /dev/null +++ b/docs/plans/e2e-dev-router-quiescencia.md @@ -0,0 +1,92 @@ +# OPS42: e2e dev — navegação RSC do login commita tarde e remonta o layout (interação silenciosamente perdida) + +Status: registrado +Atualizado em: 2026-08-11 +Issue: #691 +Priority: P3 +Model: composer-2.5 +Appetite: ~0,5 dia eng — um helper de "quiescência do router" no fixture + +## Intenção + +Em dev mode (e2e local, compile a frio), a navegação RSC do login pode commitar +**~1,8 s depois** do `goto('/campanha')` do teste: o fixture resolve o +`waitForURL` no redirect otimista, o teste segue para `goto` + interação +imediata, e a payload RSC do redirect — ainda em voo — commit por cima, +**remontando o subtree do layout** (provider do chat novo, textarea novo com +`defaultValue=''`). A interação (fill + Enter) cai no textarea velho e é +silenciosamente perdida: input volta a `''`, o envio nunca dispara, o teste +estoura em `toBeVisible`. CI (prod mode) é imune — por isso main está verde. + +## Persona e fluxo + +- **Persona:** agente rodando e2e local em dev mode com a máquina sob carga + (worktrees paralelos compilando). +- **Fluxo quebrado:** login → `page.goto('/campanha')` → `'Olá! Eu sou o +Sollinha'` visível → `fill` + `Enter` → nada acontece; o teste falha com o + input vazio e "Enviar" disabled, aparentemente sem causa. +- **Fluxo desejado:** o teste só interage depois que o router estabilizou + (nenhuma framenav/navegação RSC pendente), ou a interação sobrevive à + remontagem. + +## Objetivo e aceite + +- Interagir com a página em dev mode nunca mais perde o fill/Enter para uma + remontagem tardia do layout. +- Sem impacto em prod mode (CI) — o helper é dev-only ou inócuo em prod. +- Precedentes a seguir: `checkRadixWhenHydrated` (fixture, retry até o estado + certo) e o hardening OPS30 (login resiliente a compile frio). + +## Dados (intenção) + +Dados: N/A — infraestrutura de e2e (fixture), sem servidor/banco. + +## Direção no codebase (hipótese) + +- **Área provável:** `tests/e2e/fixtures/campaignE2EFixtures.ts` — o `login` + resolve no `waitForURL` (redirect otimista) antes do commit RSC; e/ou um + helper `waitForRouterSettled(page)` chamado após `goto` nos journeys que + interagem logo após o load (espera por uma janela de ~500 ms sem + `framenavigated`). +- **Evidência da sessão (B199, 2026-08-11):** em dev mode sob carga, o teste + B188 "reload na mesma aba" falhou 5/5 com esse mecanismo: `FRAMENAV +/campanha` (sem request document) ~1,0–1,9 s após o `HELLO`, textarea `ta2` + criada com `defaultValue=''` no instante do fill, zero writes de `.value` no + textarea (React nunca commitou o valor digitado — o estado do useChat + resetou na remontagem do provider). +- **Risco:** esperar demais (networkidle nunca chega em dev com HMR ativo) — + a janela de quiescência deve ser baseada em **ausência de framenav**, não em + network idle. + +## Dependências + +- #586 (OPS30 — hardening e2e dev: login resiliente a compile frio) — mesma + família de robustez; destrava sozinha quando o pai flipar `done`. + +## Fora de escopo + +- Consertar o comportamento do Next dev (a remontagem em si é da framework). +- O flake de `googleCalendarSync` sob carga (#686 já registra). +- Mudanças em prod mode/CI (imunes por construção). + +## Rabbit holes de produto + +- **Espera longa demais por teste:** multiplicar o tempo de cada spec em dev. + **Corte:** o helper só espera a quiescência (janela sem framenav), não + network idle; e só é chamado onde o journey interage imediatamente após o + load. +- **Camuflar falha real:** esperar quiescência pode mascarar um problema de + app. **Corte:** o helper tem timeout curto (fallback) e a falha original do + app continua aparecendo no assertion seguinte. + +## Questões em aberto (produto) + +- Nenhuma — é infraestrutura de teste. + +## Referências + +- Evidência completa da sessão B199 (mensagens de debug `TMPDEBUG2–8` no + worktree B199, 2026-08-11): `NAVREQ/FRAMENAV/LOAD` timestamps, `SET +defaultValue ta2=""`, setter de `value` nunca chamado. +- `tests/e2e/campaignSollinhaContext.e2e.spec.ts` (B188 "reload na mesma aba" + é o teste que flakeia), `tests/e2e/fixtures/campaignE2EFixtures.ts`. diff --git a/docs/plans/sollinha-close-mid-stream-persist-impl.md b/docs/plans/sollinha-close-mid-stream-persist-impl.md new file mode 100644 index 00000000..c3334001 --- /dev/null +++ b/docs/plans/sollinha-close-mid-stream-persist-impl.md @@ -0,0 +1,84 @@ +# Impl: Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência) + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #672 +Intenção: docs/plans/sollinha-close-mid-stream-persist.md +Appetite restante: herdado (~0,5 dia eng; um ajuste no persist effect do provider) + +## Leitura da intenção + +- **Outcome:** fechar o drawer em qualquer fase do turno (inclusive mid-stream) persiste `open: false` de imediato — um reload antes do settle não ressuscita o drawer; a conversa em andamento continua persistindo no settle normal. +- **O que NÃO negociar:** o mecanismo de restore/`openBy` (OPS22/B188) fica intacto — o fix é só no write de fechamento; mensagens mid-stream nunca são persistidas (reload mid-stream perde só o turno em voo, corte documentado de B188); nada de migration/servidor/Consent. +- **O que reavaliar:** a hipótese da intenção ("efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens do último commit") está correta; o refinamento é o `status === 'ready'` skip no efeito novo para não duplicar o write que o efeito de settle já faz (ver D1). + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph provider [CampaignAISidebarContext] + R[restore no mount] --> S1[settle effect
status ready: escreve msgs + open
+ atualiza settledMessagesRef] + O[open effect
open mudou + status != ready:
escreve settledMessagesRef + open] + end + S1 -.->|snapshot msgs settle-completas| REF[settledMessagesRef] + O -->|write imediato de open| W[writeSollinhaChatSession] + S1 --> W +``` + +**Opções consideradas:** A) efeito separado só para `open`, sem gate de status, gravando com o snapshot de mensagens do último settle | B) persistir tudo sem gate de status num efeito único | C) efeito separado só para fechamento (`open === false`) +**Recomendação:** **A** com um refinamento — o efeito novo pula quando `status === 'ready'`, porque o efeito de settle já escreve tudo (mensagens + `open`) nesse estado; sem o skip, um `open` mudado em repouso dispararia dois writes no mesmo commit (um com snapshot velho, um com live — correto por "último write vence", mas redundante e dependente da ordem dos efeitos). O resultado cobre o aceite: fechar mid-stream escreve `open: false` com o snapshot de mensagens settle-completas (nunca um stream pela metade). +**Rejeitadas:** **B** porque gravaria mensagens mid-stream no storage (o rabbit hole que a intenção nomeia — reload perderia a cauda do turno persistida); **C** porque abre mid-stream continuaria com `open` velho no storage (reload após abrir-durante-streaming devolveria o drawer fechado apesar da intenção do usuário) e a regra fica assimétrica sem ganho — o write em ambas as direções honra a regra OPS22 existente (open de origem `user` restaura em qualquer viewport; origem `settle` nunca restaura o drawer mobile) e o settle do desktop roda sempre em `ready`, então nenhum write mid-stream nasce da origem `settle`. + +### Componentes / mudanças + +- **`CampaignAISidebarContext.tsx`** (`src/components/campaign/shell/ai/`): + - Novo `settledMessagesRef = useRef([])` — o snapshot das mensagens do último write settle-completo; único conteúdo que um write mid-stream pode gravar. + - O persist effect atual (linhas 116-121) fica como está (gate `sessionRestored && status === 'ready'`, escreve `messages + open + openBy`) e ganha `settledMessagesRef.current = messages` no corpo, após o gate. + - Novo efeito de `open`: gate `sessionRestored && status !== 'ready'` (skips em repouso), deps `[sessionRestored, status, open]`; escreve `writeSollinhaChatSession(settledMessagesRef.current, open, openBy)`. + - `openBy` extraído para um closure local `openByForWrite()` compartilhado pelos dois efeitos (mesma regra B188: `userToggledOpenRef || restoredOpenBy === 'user'` → `'user'`, senão `'settle'`). + - Comentário do bloco B188 atualizado: mensagens só no settle; `open` é persistido imediatamente (com snapshot), nunca mensagens mid-stream. +- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: novo describe `B199` com um teste determinístico (ver Fases). +- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** Impeccable B — comportamento existente, sem superfície nova. + +## Fases verificáveis + +1. **Provider** — refatoração do persist effect (snapshot ref + efeito de `open`). `pnpm exec tsc --noEmit` + `pnpm lint`. +2. **E2E** — novo teste no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo. + - Helper `gatedMockAiChat(page)` ao lado de `mockAiChat` (mesmo wire format SSE): a **primeira** requisição responde imediato; as seguintes ficam em espera até o teste chamar `release()` (contador `requests >= 1` + promise deferida). Registrado no corpo do teste — LIFO sobre o `beforeEach` (mesmo precedente do teste de link externo do B198). + - Fluxo do teste (mobile 500px): abre o drawer → manda msg 1 → settle → poll `storedSession.open === true` (o `open: true` velho que o bug deixaria órfão) → manda msg 2 (resposta gated; chat fica busy — testemunha `status !== 'ready'` via mic desabilitado) → clica `Fechar` → drawer some → **poll `storedSession.open === false` ainda com a resposta em voo** (testemunha determinística do fix; sem o fix este poll estoura timeout) → `release()` → poll `storedSession.messages.length === 4` (o settle cobre a conversa toda, com `open: false` — nada foi descartado) → reload → drawer continua fechado → reabre pelo FAB → conversa restaurada visível (msg 1). +3. **Gates** — `pnpm gate:fast`; entrega via `pnpm push` (pipeline de execução). + +## Rabbit holes / Não escopo (engenharia) + +- Persistir mensagens mid-stream (o rabbit hole da intenção): proibido pelo corte B188 — o snapshot ref é o único caminho. +- Mexer no restore/`openBy` para "consertar" isto: anti-goal explícito — troca um bug raro por outro. +- `storage` events / sincronização entre abas, rascunho do input, largura (B166), superfície (B167): regras próprias, intactas. +- Unit tests do provider (fiação de efeitos): B188 já estabeleceu o precedente de cobrir o provider via e2e; a lib `sollinhaChatSession.ts` não muda. + +## Riscos e mitigação + +| Risco | Mitigação | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Write mid-stream grava mensagem pela metade | O efeito novo grava só `settledMessagesRef` (último settle); o write do settle continua o único a persistir mensagens live. | +| Ordem dos efeitos cria write velho-sobre-novo | Skip `status === 'ready'` no efeito novo: em repouso só o efeito de settle escreve; mid-stream só o efeito novo escreve (settle effect early-returna antes de tocar o ref). Sem sobreposição. | +| OPS22: open de origem `settle` restaura drawer mobile | Impossível mid-stream: o settle roda no mount (status `ready`); todo write mid-stream nasce de `requestOpen`/`toggle` (origem `user`). Regra de restore intacta. | +| Drawer fecha com animação; `toHaveCount(0)` flaky | Precedente do B198 no mesmo arquivo usa `toHaveCount(0)`; assert posterior do storage torna o teste independente da animação. | +| O teste depende de timing do stream | Não: a resposta da 2ª troca fica gated (fetch pendente) — o close acontece com o chat comprovadamente busy (mic desabilitado), sem corrida. | +| StrictMode (dev) duplica efeitos | Writes idempotentes; restore já é ref-guarded (B188); e2e roda com StrictMode real e cobre o conjunto. | + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (close mid-stream persiste `open: false`; conversa persiste no settle; restore/`openBy` intocados) +- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; identificadores em inglês; pt-BR só em strings de UI) +- [ ] Testes de domínio previstos: e2e determinístico de fechamento mid-stream + settle cobrindo a conversa (sem unit — lib intocada) + +## Decisões de engenharia (self-score) + +| Decisão | Recomendação | Rejeitadas | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Onde persistir o close mid-stream | Efeito novo de `open` no provider (gate `sessionRestored`, skip `ready`), gravando snapshot de mensagens | Escrever tudo sem gate de status (persiste stream pela metade); fechar no handler do X (lógica no componente, fora do ciclo React do estado) | +| Qual conteúdo de mensagens no write mid-stream | `settledMessagesRef` — último snapshot settle-completo | `messages` live (stream em voo); omitir mensagens (quebraria o shape/sessão e um reload perderia tudo) | +| Quando o efeito novo escreve | Qualquer mudança de `open` fora de `ready` (abrir e fechar) | Só fechamento (assimétrico; abrir mid-stream deixaria storage velho) | +| Teste | E2E com resposta gated (fetch pendente) + poll do storage como testemunha | Delay fixo no stream (flaky); simular estado via storage manual (não cobre o provider) | + +**Self-score decision-quality: 5/5** — aceite inviolado (close mid-stream persiste imediato; conversa intacta no settle; restore intocado); rabbit hole nomeado e cortado pelo snapshot ref; reusa os donos existentes (mesmo efeito de persist, mesma lib, mesmo wire format de mock); a divergência da hipótese da intenção é defensiva e documentada (skip `ready` evita write duplicado sem mudar comportamento). diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index e93da2dd..75a235be 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -78,12 +78,22 @@ export const CampaignAISidebarProvider = ({ const restoredSessionRef = useRef(false) const sessionReadRef = useRef(false) const restoredOpenByRef = useRef('settle') + // B199: the snapshot of the last settle-complete messages — the ONLY + // content an `open` write made mid-stream may persist (writing live + // messages would freeze a half-received turn in the session). + const settledMessagesRef = useRef([]) + // B199: the `open` value of the last write by either persist effect — lets + // the mid-stream effect skip status transitions that leave `open` untouched. + const lastWrittenOpenRef = useRef(open) useEffect(() => { if (sessionReadRef.current || !measured) return sessionReadRef.current = true const session = readSollinhaChatSession() if (session) { setMessages(session.messages) + // B199: the mid-stream `open` write must never clobber a restored + // conversation with an empty snapshot — seed it from the restore itself. + settledMessagesRef.current = session.messages const openBy = session.openBy ?? 'settle' restoredOpenByRef.current = openBy if (session.open && (openBy === 'user' || !isMobile)) setOpen(true) @@ -107,19 +117,35 @@ export const CampaignAISidebarProvider = ({ // B188: persist the conversation only once the chat has settled — writing // during streaming would freeze a half-received message in the session - // (a reload mid-stream loses only that in-flight turn). `open` changes made - // mid-stream are picked up by the settle that follows. Empty state is still + // (a reload mid-stream loses only that in-flight turn). Empty state is still // written: closing an empty chat must overwrite a stored `open: true`, // otherwise a reload would reopen a drawer the user just closed. The // persisted `openBy` tells the next page whether the open was the user's // (restore it anywhere) or the desktop settle's (desktop-only, OPS22). + const openByForWrite = (): SollinhaChatSessionOpenOrigin => + userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle' useEffect(() => { if (!sessionRestored || status !== 'ready') return - const openBy = - userToggledOpenRef.current || restoredOpenByRef.current === 'user' ? 'user' : 'settle' - writeSollinhaChatSession(messages, open, openBy) + settledMessagesRef.current = messages + lastWrittenOpenRef.current = open + writeSollinhaChatSession(messages, open, openByForWrite()) }, [sessionRestored, status, messages, open]) + // B199: persist `open` without waiting for the settle — closing the drawer + // mid-stream must reach the storage immediately, or a reload would restore + // the stale `open: true` and resurrect the drawer. Messages stay at the last + // settle-complete snapshot until the next settle (in the abort path the + // snapshot IS the terminal content — the documented B188 cut). Skipping + // `ready` avoids a duplicate write of the settle effect above, and the + // `lastWrittenOpenRef` guard skips plain status transitions that leave + // `open` untouched. Deliberately no `messages` dep: never persist a live + // stream, only the open flag over the snapshot. + useEffect(() => { + if (!sessionRestored || status === 'ready' || lastWrittenOpenRef.current === open) return + lastWrittenOpenRef.current = open + writeSollinhaChatSession(settledMessagesRef.current, open, openByForWrite()) + }, [sessionRestored, status, open]) + // The chat has two surfaces keyed by viewport — the desktop panel and the // mobile drawer — and both derive from the SAME `open` flag. Crossing the // breakpoint therefore needs no migration of its own: the surface that renders diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index 8a4b7777..a324617d 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -7,30 +7,72 @@ const SESSION_KEY = 'teqo:campaign:sollinha-chat-session' const DEFAULT_REPLY = 'Resposta mockada com [Municípios](/campanha/municipios).' const EXTERNAL_REPLY = 'Resposta mockada com [portal da saúde](https://www.saude.ba.gov.br/).' +/** + * The SSE body the mocks reply with. Chunks follow the SDK v7 UI-message-stream + * wire format (start / text-start / text-delta / text-end / finish) — a bare + * legacy `{"type":"text"}` chunk fails the client schema and the chat would + * never settle. + */ +const streamBody = (reply: string) => + [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-start","id":"t1"}\n\n', + `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`, + 'data: {"type":"text-end","id":"t1"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + ].join('') + /** * Mock the AI endpoint with a minimal SSE stream so sending a message neither - * needs a DeepSeek API key nor hits the real rate limiter. The chunks follow - * the SDK v7 UI-message-stream wire format (start / text-start / text-delta / - * text-end / finish) — a bare legacy `{"type":"text"}` chunk fails the client - * schema and the chat would never settle. The reply carries a markdown link so - * the "navigate without reload" path has a real anchor. + * needs a DeepSeek API key nor hits the real rate limiter. The reply carries a + * markdown link so the "navigate without reload" path has a real anchor. */ const mockAiChat = (page: Page, reply: string = DEFAULT_REPLY) => page.route('**/campanha/api/ai-chat', async (route) => { await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, - body: [ - 'data: {"type":"start"}\n\n', - 'data: {"type":"text-start","id":"t1"}\n\n', - `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`, - 'data: {"type":"text-end","id":"t1"}\n\n', - 'data: {"type":"finish","finishReason":"stop"}\n\n', - ].join(''), + body: streamBody(reply), }) }) +/** + * Mock whose FIRST request replies immediately and every later request hangs + * until the test calls `release()` — a deterministic window in which the chat + * is still busy (`status !== 'ready'`) with a reply blocked, with no stream + * timing to race against. + */ +const gatedMockAiChat = (page: Page) => { + let resolveGate: (() => void) | undefined + const gate = new Promise((resolve) => { + resolveGate = resolve + }) + let requests = 0 + void page.route('**/campanha/api/ai-chat', async (route) => { + const index = requests + requests += 1 + if (index >= 1) await gate + try { + await route.fulfill({ + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: streamBody(DEFAULT_REPLY), + }) + } catch { + // An early failure may end the test before `release()` — fulfilling on a + // closed page rejects, and the test's own error is the signal to read. + } + }) + return { + release: () => { + resolveGate?.() + }, + requests: () => requests, + } +} + const MESSAGE = 'Mensagem que sobrevive ao reload' +const SECOND_MESSAGE = 'Segunda pergunta' const openChatAndSend = async (page: Page) => { await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 }) @@ -258,3 +300,70 @@ test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mes await expect(drawer).toBeVisible() }) }) + +test.describe('B199 — fechar o drawer durante streaming persiste fechado no reload', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await mockAiChat(page) + }) + + test('fechar mid-stream grava open:false e o reload não reabre o drawer', async ({ + page, + campaign, + }) => { + // Last-registered route wins (LIFO) — overrides the beforeEach mock. The + // first exchange replies immediately; the second stays blocked until + // `release()`, so the close provably lands while the chat is busy. + const gated = gatedMockAiChat(page) + + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Fechar Mid-Stream'), + }) + await page.setViewportSize({ width: 500, height: 800 }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + const drawer = await openMobileDrawer(page) + + await openChatAndSend(page) + await waitForChatSettled(page) + // The stale `open: true` the bug would leave orphaned in the storage. + await expect.poll(async () => (await storedSession(page))?.open).toBe(true) + + // Second exchange — the gated reply keeps the chat busy (status !== 'ready'). + const input = page.getByRole('textbox', { name: 'Pergunte para o Sollinha...' }) + await input.fill(SECOND_MESSAGE) + await input.press('Enter') + await expect(page.getByText(SECOND_MESSAGE)).toBeVisible({ timeout: 20_000 }) + await expect( + page.getByRole('button', { name: 'Falar pergunta (voz)' }).filter({ visible: true }), + ).toBeDisabled({ timeout: 20_000 }) + + // Close while the reply is still blocked: the persist must not wait for + // the settle, or a reload would restore the stale `open: true`. + await drawer.getByRole('button', { name: 'Fechar' }).click() + await expect(drawer).toHaveCount(0) + await expect.poll(async () => (await storedSession(page))?.open).toBe(false) + + // Let the reply land: the settle still persists the whole conversation. + gated.release() + await expect.poll(async () => (await storedSession(page))?.messages?.length).toBe(4) + // Both exchanges went through the gated route — the LIFO override worked. + expect(gated.requests()).toBe(2) + + // Reload: no ghost drawer, conversation restored. + await page.reload() + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) + // The FAB is SSR'd; a click that lands before hydration is a silent no-op + // (B13/B17 class) — retry until the drawer actually opens. + const fab = page.getByRole('button', { name: 'Sollinha — Assistente virtual' }) + await expect(async () => { + await fab.click({ timeout: 1_000 }) + await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toBeVisible( + { timeout: 1_000 }, + ) + }).toPass({ timeout: 15_000 }) + await expect(page.getByText(MESSAGE)).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText(SECOND_MESSAGE)).toBeVisible() + }) +})