import { afterEach, describe, expect, it } from 'vitest' import type { UIMessage } from 'ai' import { SOLLINHA_CHAT_MAX_BYTES, SOLLINHA_CHAT_MAX_MESSAGES, SOLLINHA_CHAT_SESSION_STORAGE_KEY, pruneSollinhaChatMessages, readSollinhaChatSession, writeSollinhaChatSession, } from '@/lib/sollinhaChatSession' const makeMessage = (id: string, role: UIMessage['role'], text = `msg ${id}`): UIMessage => ({ id, role, parts: [{ type: 'text', text }], }) describe('sollinhaChatSession storage', () => { afterEach(() => { window.sessionStorage.clear() }) it('returns null when storage is missing or invalid (fail-closed)', () => { expect(readSollinhaChatSession('coordinator')).toBeNull() window.sessionStorage.setItem(SOLLINHA_CHAT_SESSION_STORAGE_KEY, 'not-json') expect(readSollinhaChatSession('coordinator')).toBeNull() window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 2, role: 'coordinator', messages: [], open: false }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, role: 'coordinator', messages: 'nope', open: false }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, role: 'coordinator', messages: [{ id: 1, role: 'user' }], open: false, }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, role: 'coordinator', messages: [], open: 'yes' }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() }) it('round-trips messages and open state', () => { const messages = [makeMessage('1', 'user'), makeMessage('2', 'assistant')] writeSollinhaChatSession(messages, true, 'coordinator') const session = readSollinhaChatSession('coordinator') expect(session?.messages).toEqual(messages) expect(session?.open).toBe(true) expect(session?.role).toBe('coordinator') }) it('round-trips the open origin and defaults to settle without one', () => { writeSollinhaChatSession([], true, 'coordinator', 'user') expect(readSollinhaChatSession('coordinator')?.openBy).toBe('user') writeSollinhaChatSession([], true, 'coordinator', 'settle') expect(readSollinhaChatSession('coordinator')?.openBy).toBe('settle') // No origin passed — a write without intent must never restore a drawer. writeSollinhaChatSession([], true, 'coordinator') expect(readSollinhaChatSession('coordinator')?.openBy).toBe('settle') }) it('rejects an invalid open origin (fail-closed)', () => { window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, role: 'coordinator', messages: [], open: true, openBy: 'system', }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() }) it('still reads sessions without an open origin', () => { window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, role: 'coordinator', messages: [], open: true }), ) expect(readSollinhaChatSession('coordinator')?.openBy).toBeUndefined() }) it('discards sessions written before the role field (C159, fail-closed)', () => { window.sessionStorage.setItem( SOLLINHA_CHAT_SESSION_STORAGE_KEY, JSON.stringify({ version: 1, messages: [], open: true }), ) expect(readSollinhaChatSession('coordinator')).toBeNull() }) it('never restores another role’s conversation (C159)', () => { writeSollinhaChatSession( [makeMessage('1', 'user'), makeMessage('2', 'assistant')], true, 'coordinator', ) expect(readSollinhaChatSession('communicator')).toBeNull() expect(readSollinhaChatSession('coordinator')?.messages).toHaveLength(2) }) it('prunes to the message-count guardrail keeping the newest', () => { const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) => makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'), ) const pruned = pruneSollinhaChatMessages(messages) // 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([ makeMessage('1', 'user'), makeMessage('2', 'assistant', big), makeMessage('3', 'user'), ]) expect(pruned).toHaveLength(1) expect(pruned[0]?.id).toBe('3') }) it('writes the pruned session, never the raw messages', () => { const messages = Array.from({ length: SOLLINHA_CHAT_MAX_MESSAGES + 5 }, (_, index) => makeMessage(String(index), index % 2 === 0 ? 'user' : 'assistant'), ) writeSollinhaChatSession(messages, false, 'coordinator') const session = readSollinhaChatSession('coordinator') 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) }) it('keeps tool outputs intact so the restored history round-trips to the model', () => { const messages = [ makeMessage('1', 'user'), // The lib treats `parts` opaquely — the shape here mirrors what a real // settled stream produces (tool part with its output) without depending // on the SDK's tool-typed part union. { id: '2', role: 'assistant', parts: [ { type: 'tool-invocation', toolCallId: 'call-1', toolName: 'calculate', state: 'output-available', input: { expression: '1+1' }, output: 2, }, { type: 'text', text: 'O resultado é 2.' }, ], }, ] as unknown as UIMessage[] writeSollinhaChatSession(messages, true, 'coordinator') expect(readSollinhaChatSession('coordinator')?.messages).toEqual(messages) }) })