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