fix(B188): simplify — settle não reabre chat fechado no reload desktop; prune com cabeça de pergunta; node fora do Link; mock SSE v7 no B167

- P1: B167 settle forçava open=true no load desktop mesmo com sessão restaurada
  open:false (storage auto-curava para true); restore agora vence quando existe
- prune: cabeça assistant quebra convertToModelMessages; drop até ancorar em user
- MarkdownLink: node do react-markdown não vaza para <Link>/<a>
- isUIMessage: parts com shape inválido falham-closed no restore
- B167 mock: chunk legado {"type":"text"} falha schema v7 (status preso em erro);
  agora usa start/text-start/text-delta/text-end/finish
This commit is contained in:
Francisco Solla 2026-08-10 01:34:53 -03:00
parent ca1dbb80f7
commit 5380154dca
6 changed files with 56 additions and 20 deletions

View file

@ -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).

View file

@ -26,6 +26,7 @@ const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/
const MarkdownLink = ({
href,
node: _node,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => {

View file

@ -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 <AISidebarContext.Provider value={value}>{children}</AISidebarContext.Provider>

View file

@ -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
}

View file

@ -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({

View file

@ -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)
})