diff --git a/docs/plans/sollinha-contexto-sessao-janela.md b/docs/plans/sollinha-contexto-sessao-janela.md index d4e351d3..d61ed253 100644 --- a/docs/plans/sollinha-contexto-sessao-janela.md +++ b/docs/plans/sollinha-contexto-sessao-janela.md @@ -29,7 +29,7 @@ O Sollinha foi pensado para trabalhar **em colaboração** com o usuário, mas h ## Objetivo e aceite - Clicar num link do Sollinha navega **sem recarregar a página** (navegação por cliente do app), e a conversa continua presente ao reabrir o chat. -- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento). +- Recarregar a página na mesma aba restaura a conversa (mensagens e estado em andamento). _Nota de impl (B188): a recarga no meio de uma resposta perde apenas aquele turno em voo — o storage só guarda o último estado settle; o chat recomeça `ready` e o usuário repete a pergunta._ - Fechar a aba apaga tudo; outra aba inicia sem histórico. - Nada é gravado no servidor/banco; nada cruza abas ou dispositivos. - Se o chat estava aberto ao recarregar, ele volta aberto na mesma superfície (painel/drawer conforme o viewport). diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index ec3e6056..458e9e7a 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -26,6 +26,7 @@ const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/ const MarkdownLink = ({ href, + node: _node, children, ...props }: React.AnchorHTMLAttributes & { node?: unknown }) => { diff --git a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx index 8851b354..82d06153 100644 --- a/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx +++ b/src/components/campaign/shell/ai/CampaignAISidebarContext.tsx @@ -49,8 +49,6 @@ export const CampaignAISidebarProvider = ({ id: 'campaign-sollinha', transport: useMemo(() => new DefaultChatTransport({ api: '/campanha/api/ai-chat' }), []), }) - // `setMessages` is a stable `useCallback` over the chat instance — safe as - // the single dependency of the mount-only restore effect. const { messages, setMessages, status } = chat // B188: restore the conversation and the open state from the tab's @@ -59,19 +57,23 @@ export const CampaignAISidebarProvider = ({ // `sessionRestored` gates the persist effect so the mount commit (empty // pre-restore values) cannot overwrite a stored session. const [sessionRestored, setSessionRestored] = useState(false) + const restoredSessionRef = useRef(false) useEffect(() => { const session = readSollinhaChatSession() if (session) { setMessages(session.messages) setOpen(session.open) } + restoredSessionRef.current = session !== null setSessionRestored(true) }, [setMessages]) // 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. + // mid-stream are picked up by the settle that follows. 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. useEffect(() => { if (!sessionRestored || status !== 'ready') return writeSollinhaChatSession(messages, open) @@ -84,12 +86,14 @@ export const CampaignAISidebarProvider = ({ // on the desktop settle the panel starts at its RRP default (25%) while `open` // begins false — reconcile `open` to the panel's real visibility so a visible // chat is "open" (makes close work and the drawer open on resize). A fresh - // mobile visit keeps `open` false (no spontaneous chat). + // mobile visit keeps `open` false (no spontaneous chat). When a session was + // restored, the restored `open` wins instead — a chat closed before the + // reload must not be forced back open by the settle (B188). const settledRef = useRef(false) useEffect(() => { if (settledRef.current) return settledRef.current = measured - if (measured && !isMobile && !panelRef.current?.isCollapsed()) { + if (measured && !isMobile && !restoredSessionRef.current && !panelRef.current?.isCollapsed()) { setOpen(true) } }, [measured, isMobile, panelRef]) @@ -101,13 +105,13 @@ export const CampaignAISidebarProvider = ({ open, isMobile, measured, - messages: chat.messages, - status: chat.status, + messages, + status, sendMessage: chat.sendMessage, setOpen, toggle, }), - [open, isMobile, measured, chat.messages, chat.status, chat.sendMessage, setOpen, toggle], + [open, isMobile, measured, messages, status, chat.sendMessage, setOpen, toggle], ) return {children} diff --git a/src/lib/sollinhaChatSession.ts b/src/lib/sollinhaChatSession.ts index 5360bf6d..d46f8735 100644 --- a/src/lib/sollinhaChatSession.ts +++ b/src/lib/sollinhaChatSession.ts @@ -33,7 +33,8 @@ const isUIMessage = (value: unknown): value is UIMessage => { typeof value.id === 'string' && typeof value.role === 'string' && MESSAGE_ROLES.has(value.role) && - Array.isArray(value.parts) + Array.isArray(value.parts) && + value.parts.every((part) => isRecord(part) && typeof part.type === 'string') ) } @@ -61,9 +62,11 @@ export const readSollinhaChatSession = (): SollinhaChatSession | null => { } /** - * Drops oldest messages until the count and byte guardrails hold. Tool outputs - * are kept intact: `convertToModelMessages` turns complete tool parts back into - * `tool-call` + `tool-result` model messages, so the model keeps the full + * Drops oldest messages until the count and byte guardrails hold, then ensures + * the head is a user message: `convertToModelMessages` (server) rejects a + * conversation that starts with an assistant turn, and pruning from the front + * can leave one. Tool outputs are kept intact: complete tool parts turn back + * into `tool-call` + `tool-result` model messages, so the model keeps the full * context when the restored conversation continues. */ export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] => { @@ -75,6 +78,9 @@ export const pruneSollinhaChatMessages = (messages: UIMessage[]): UIMessage[] => if (JSON.stringify(pruned).length <= SOLLINHA_CHAT_MAX_BYTES) break pruned = pruned.slice(1) } + while (pruned.length > 1 && pruned[0]?.role !== 'user') { + pruned = pruned.slice(1) + } return pruned } diff --git a/tests/e2e/campaignAiChatResize.e2e.spec.ts b/tests/e2e/campaignAiChatResize.e2e.spec.ts index 764f9557..143a86d5 100644 --- a/tests/e2e/campaignAiChatResize.e2e.spec.ts +++ b/tests/e2e/campaignAiChatResize.e2e.spec.ts @@ -5,14 +5,23 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js' /** * 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 user message is - * still recorded client-side, which is all these assertions need. + * still recorded client-side, which is all these assertions need. 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 leaves the chat stuck in an error status. */ const mockAiChat = (page: Page) => page.route('**/campanha/api/ai-chat', async (route) => { await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream' }, - body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n', + body: [ + 'data: {"type":"start"}\n\n', + 'data: {"type":"text-start","id":"t1"}\n\n', + 'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada da Sollinha."}\n\n', + 'data: {"type":"text-end","id":"t1"}\n\n', + 'data: {"type":"finish","finishReason":"stop"}\n\n', + ].join(''), }) }) @@ -143,7 +152,6 @@ test.describe('B167 — chat Sollinha migra entre painel e drawer ao redimension await campaign.login(page, user.email!, user.password) // Carga direto em mobile: sem drawer espontâneo, só o FAB. - await page.setViewportSize({ width: 500, height: 800 }) await page.goto('/campanha') await expect(page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'Sollinha — Assistente virtual' })).toBeVisible({ diff --git a/tests/unit/sollinhaChatSession.unit.spec.ts b/tests/unit/sollinhaChatSession.unit.spec.ts index e6cbe60c..5957b5b1 100644 --- a/tests/unit/sollinhaChatSession.unit.spec.ts +++ b/tests/unit/sollinhaChatSession.unit.spec.ts @@ -61,11 +61,27 @@ describe('sollinhaChatSession storage', () => { makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'), ) const pruned = pruneSollinhaChatMessages(messages) - expect(pruned).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES) - expect(pruned[0]?.id).toBe('5') + // Dropping the 5 oldest turns leaves an assistant head (id "5"); the head + // rule then drops it too, so the conversation still opens with a question. + expect(pruned).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1) + expect(pruned[0]?.id).toBe('6') + expect(pruned[0]?.role).toBe('user') expect(pruned.at(-1)?.id).toBe(String(SOLLINHA_CHAT_MAX_MESSAGES + 4)) }) + it('drops leading assistant turns after pruning so the model history opens with a question', () => { + const messages = [ + makeMessage('0', 'assistant'), + makeMessage('1', 'user'), + makeMessage('2', 'assistant'), + ] + const pruned = pruneSollinhaChatMessages(messages) + expect(pruned).toHaveLength(2) + expect(pruned[0]?.role).toBe('user') + // A single assistant message survives (a user turn would be invented). + expect(pruneSollinhaChatMessages([makeMessage('0', 'assistant')])).toHaveLength(1) + }) + it('prunes to the byte guardrail dropping the oldest until it fits', () => { const big = 'x'.repeat(SOLLINHA_CHAT_MAX_BYTES) const pruned = pruneSollinhaChatMessages([ @@ -83,8 +99,9 @@ describe('sollinhaChatSession storage', () => { ) writeSollinhaChatSession(messages, false) const session = readSollinhaChatSession() - expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES) - expect(session?.messages[0]?.id).toBe('5') + expect(session?.messages).toHaveLength(SOLLINHA_CHAT_MAX_MESSAGES - 1) + expect(session?.messages[0]?.id).toBe('6') + expect(session?.messages[0]?.role).toBe('user') expect(session?.open).toBe(false) })