teqo-backup/tests/unit/sollinhaChatSession.unit.spec.ts

189 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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