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('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)
})
})
describe('Chat store — id-first tool matching', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('matches by tool_call_id with two same-name pending cards', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'ssh_exec', args: {}, tool_call_id: 'tc1' })
store.onToolStarted({ tool: 'ssh_exec', args: {}, tool_call_id: 'tc2' })
store.onToolCall({ tool: 'ssh_exec', tool_call_id: 'tc2', result: 'second', success: true })
const tools = store.streamingMsg.tools
expect(tools[0].pending).toBe(true) // tc1 untouched
expect(tools[1].result).toBe('second')
expect(tools[1].pending).toBe(false)
})
it('resolves a late tool_call after stream_end via the index', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'terminal', args: {}, tool_call_id: 'tc9' })
const msg = store.streamingMsg
store.onStreamEnd({})
store.onToolCall({ tool: 'terminal', tool_call_id: 'tc9', result: 'ok', success: true })
expect(msg.tools[0].result).toBe('ok')
expect(msg.tools[0].pending).toBe(false)
})
it('bare tool_call without tool_started synthesizes a card', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolCall({ tool: 'recall', tool_call_id: 'tcX', result: 'r1', success: true })
const tools = store.streamingMsg.tools
expect(tools).toHaveLength(1)
expect(tools[0]).toMatchObject({ name: 'recall', result: 'r1', pending: false, toolCallId: 'tcX' })
})
it('subagent tool_call routes into the spawn card via the index', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'spawn_agent', args: {}, tool_call_id: 'sp1' })
store.onToolStarted({ tool: 'ls', args: {}, tool_call_id: 'sub1', is_subagent: true })
const spawn = store.streamingMsg.tools[0]
expect(spawn.steps).toHaveLength(1)
store.onToolCall({ tool: 'ls', tool_call_id: 'sub1', result: 'out', success: true, is_subagent: true })
expect(spawn.steps[0].result).toBe('out')
expect(spawn.steps[0].pending).toBe(false)
})
})
describe('Chat store — background tasks and message queue', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('task_update running adds chip entry; terminal status removes it', () => {
const store = useChatStore()
store.onTaskUpdate({ task_id: 'bt-1', tool: 'terminal', status: 'running', started_at: 'T' })
expect(store.backgroundTasks['bt-1']).toMatchObject({ taskId: 'bt-1', tool: 'terminal' })
store.onTaskUpdate({ task_id: 'bt-1', tool: 'terminal', status: 'completed', result_preview: 'done' })
expect(store.backgroundTasks['bt-1']).toBeUndefined()
})
it('task_update with parent_tool_call_id appends a step to the tool card', () => {
const store = useChatStore()
store.onStreamStart()
store.onToolStarted({ tool: 'spawn_agent', args: {}, tool_call_id: 'sp2' })
store.onTaskUpdate({
task_id: 'bt-2', tool: 'spawn_agent', status: 'completed',
parent_tool_call_id: 'sp2', result_preview: 'ok', subagent_tokens: 500,
})
const spawn = store.streamingMsg.tools[0]
expect(spawn.steps).toHaveLength(1)
expect(spawn.steps[0]).toMatchObject({ kind: 'task_update', status: 'completed', preview: 'ok' })
expect(spawn.subagentTokens).toBe(500)
})
it('message_queued sets count; stream_start clears it', () => {
const store = useChatStore()
store.onMessageQueued({ position: 1, queue_len: 2, max: 5 })
expect(store.queuedMessages.count).toBe(2)
store.onStreamStart()
expect(store.queuedMessages.count).toBe(0)
})
it('terminal_opened seeds terminal metadata', () => {
const store = useChatStore()
store.onTerminalOpened({ terminal_name: 'ops', description: 'd', pid: 5, background: true })
expect(store.terminals['ops']).toMatchObject({ name: 'ops', closed: false })
})
})