import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from '@/stores/chat.js'
vi.mock('@/stores/sessions.js', () => ({
useSessionsStore: () => ({
updatePreview: vi.fn(),
updateName: vi.fn(),
sessions: [],
}),
}))
vi.mock('@/api/index.js', () => ({
getSession: vi.fn(),
getFeedback: vi.fn().mockResolvedValue({ feedback: [] }),
setFeedback: vi.fn(),
generateSessionName: vi.fn(),
}))
import * as api from '@/api/index.js'
describe('Chat store — buildMessageList', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('empty array → empty result', () => {
const store = useChatStore()
expect(store.buildMessageList([])).toEqual([])
})
it('single user message', () => {
const store = useChatStore()
const raw = [{ role: 'user', content: 'hi', created_at: 'T1' }]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ role: 'user', text: 'hi', time: 'T1' })
})
it('assistant with final text', () => {
const store = useChatStore()
const raw = [{ role: 'assistant', content: 'hello', created_at: 'T2' }]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ role: 'assistant', text: 'hello', done: true })
})
it('assistant tool call + tool result', () => {
const store = useChatStore()
const raw = [
{ role: 'assistant', tool_calls: [{ id: 'tc1', name: 'test_tool', arguments: { x: 1 } }] },
{ role: 'tool', tool_call_id: 'tc1', name: 'test_tool', content: 'ok' },
{ role: 'assistant', content: 'done' },
]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
const msg = result[0]
expect(msg.role).toBe('assistant')
expect(msg.text).toBe('done')
expect(msg.tools).toHaveLength(1)
expect(msg.tools[0]).toMatchObject({ kind: 'tool', name: 'test_tool', result: 'ok', success: true })
})
it('marks tool failed when content starts with Error:', () => {
const store = useChatStore()
const raw = [
{ role: 'assistant', tool_calls: [{ id: 'tc1', name: 'bad', arguments: {} }] },
{ role: 'tool', tool_call_id: 'tc1', name: 'bad', content: 'Error: failed' },
{ role: 'assistant', content: 'sorry' },
]
const result = store.buildMessageList(raw)
expect(result[0].tools[0].success).toBe(false)
})
it('skips orphan tool messages', () => {
const store = useChatStore()
const raw = [
{ role: 'user', content: 'hi' },
{ role: 'tool', tool_call_id: 'x', name: 'orphan', content: 'lost' },
]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
expect(result[0].role).toBe('user')
})
it('includes compression notice', () => {
const store = useChatStore()
const raw = [{ role: 'system', is_compression: true, content: 'summary' }]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ role: 'system', type: 'compression_notice', summary: 'summary' })
})
it('includes summary card', () => {
const store = useChatStore()
const raw = [{ role: 'assistant', is_summary: true, content: ' recap' }]
const result = store.buildMessageList(raw)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ type: 'summary', text: ' recap' })
})
it('propagates metrics from assistant turns', () => {
const store = useChatStore()
const raw = [
{ role: 'assistant', content: 'hi', elapsed_seconds: 1.5, token_count: 42 },
]
const result = store.buildMessageList(raw)
expect(result[0]).toMatchObject({ elapsed_seconds: 1.5, token_count: 42 })
})
it('handles images in user message', () => {
const store = useChatStore()
const raw = [{ role: 'user', content: 'look', images: ['data:image/png;base64,abc'] }]
const result = store.buildMessageList(raw)
expect(result[0].images[0]).toBe('data:image/png;base64,abc')
})
it('wraps bare image buffers with data URI', () => {
const store = useChatStore()
const raw = [{ role: 'user', content: 'look', images: ['abc123'] }]
const result = store.buildMessageList(raw)
expect(result[0].images[0]).toBe('data:image/jpeg;base64,abc123')
})
it('injects plan card into tools', () => {
const store = useChatStore()
const raw = [
{ role: 'assistant', is_plan: true, content: 'do A then B' },
{ role: 'assistant', content: 'ok' },
]
const result = store.buildMessageList(raw)
expect(result[0].tools).toHaveLength(1)
expect(result[0].tools[0]).toMatchObject({ kind: 'plan', text: 'do A then B' })
})
})
describe('Chat store — WS handlers', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('onStreamStart creates streaming message', () => {
const store = useChatStore()
store.onStreamStart()
expect(store.streaming).toBe(true)
expect(store.messages).toHaveLength(1)
expect(store.messages[0].role).toBe('assistant')
expect(store.messages[0].type).toBe('stream')
expect(store.streamingMsg).toBeTruthy()
})
it('onStreamDelta appends to streaming text', () => {
const store = useChatStore()
store.onStreamStart()
store.onStreamDelta('hello')
store.onStreamDelta(' world')
expect(store.streamingMsg.text).toBe('hello world')
})
it('onStreamEnd finalizes message', () => {
const store = useChatStore()
store.onStreamStart()
store.onStreamDelta('done')
store.onStreamEnd({ context_tokens: 100, max_context_tokens: 200 })
expect(store.streaming).toBe(false)
expect(store.streamingMsg).toBeNull()
expect(store.messages[0].done).toBe(true)
expect(store.contextTokens).toBe(100)
expect(store.maxContextTokens).toBe(200)
})
it('onStreamEnd purges empty message', () => {
const store = useChatStore()
store.onStreamStart()
store.onStreamEnd({})
expect(store.messages).toHaveLength(0)
})
it('onToolStarted adds pending card', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'ls', args: { path: '/' }, is_subagent: false })
const msg = store.streamingMsg
expect(msg.tools).toHaveLength(1)
expect(msg.tools[0]).toMatchObject({ kind: 'tool', name: 'ls', pending: true })
})
it('onToolCall resolves pending card', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'ls', args: {}, is_subagent: false })
store.onToolCall({ tool: 'ls', result: 'file.txt', success: true, is_subagent: false })
const card = store.streamingMsg.tools[0]
expect(card.pending).toBe(false)
expect(card.result).toBe('file.txt')
expect(card.success).toBe(true)
})
it('onUiComponent appends to streaming message', () => {
const store = useChatStore()
store.onStreamStart()
store.onUiComponent({ component: 'table', payload: { rows: 2 } })
const msg = store.streamingMsg
expect(msg.tools).toHaveLength(1)
expect(msg.tools[0]).toMatchObject({ kind: 'ui_component', component: 'table', payload: { rows: 2 } })
})
it('onUiComponent creates standalone message when not streaming', () => {
const store = useChatStore()
store.onUiComponent({ component: 'chart', payload: { value: 42 } })
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({ role: 'assistant', type: 'ui_component', component: 'chart', payload: { value: 42 } })
})
it('onError pushes error message', () => {
const store = useChatStore()
store.onError({ message: 'boom' })
expect(store.messages).toHaveLength(1)
expect(store.messages[0]).toMatchObject({ role: 'system', type: 'error', text: 'boom' })
expect(store.streaming).toBe(false)
})
it('onContextCompressed pushes notice', () => {
const store = useChatStore()
store.contextTokens = 78
store.maxContextTokens = 100
store.onContextCompressed({
messages_before: 10,
messages_after: 5,
summary: 's',
context_tokens: 0,
max_context_tokens: 100,
})
expect(store.messages[0]).toMatchObject({ type: 'compression_notice', before: 10, after: 5 })
expect(store.contextTokens).toBe(0)
expect(store.maxContextTokens).toBe(100)
})
it('appendUserMessage adds user card', () => {
const store = useChatStore()
store.appendUserMessage('hi', [], [])
expect(store.messages[0]).toMatchObject({ role: 'user', text: 'hi' })
})
it('loadSession resets state and loads messages', async () => {
api.getSession.mockResolvedValue({
session_id: 's1',
profile_id: 'secretary',
messages: [{ role: 'user', content: 'hi' }],
context_token_count: 50,
max_context_tokens: 100,
})
const store = useChatStore()
await store.loadSession('s1')
expect(store.currentId).toBe('s1')
expect(store.currentProfileId).toBe('secretary')
expect(store.messages).toHaveLength(1)
expect(store.loading).toBe(false)
})
it('clearSession resets everything', () => {
const store = useChatStore()
store.messages = [{ role: 'user', text: 'x' }]
store.currentId = 's1'
store.clearSession()
expect(store.currentId).toBeNull()
expect(store.messages).toHaveLength(0)
})
})