Newer
Older
navi-1 / webclient / tests / unit / stores / chat.test.js
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)
  })
})