The AI chat was using both --resume (CLI session resumption) AND formatted conversation history in the prompt simultaneously. This dual-context approach confused the model — it saw the conversation twice (from session + from prompt markup), leading to "I don't have context" responses on follow-ups. Fix: remove --resume entirely from chat mode. Each CLI call is now independent, with full conversation history formatted into the prompt via <conversation_history> markup. trimHistory handles graceful truncation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
|
|
// Capture what streamClaudeChat receives
|
|
const streamClaudeChatMock = vi.fn<
|
|
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
|
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
|
>()
|
|
|
|
vi.mock('../utils/ai-chat', async () => {
|
|
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
|
return {
|
|
...actual,
|
|
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
|
streamClaudeChatMock(...args)
|
|
// Simulate async: call onDone after a tick
|
|
const callbacks = args[3]
|
|
setTimeout(() => {
|
|
callbacks.onInit?.('test-session')
|
|
callbacks.onText('mock response')
|
|
callbacks.onDone()
|
|
}, 10)
|
|
return Promise.resolve('test-session')
|
|
},
|
|
}
|
|
})
|
|
|
|
import { useAIChat } from './useAIChat'
|
|
|
|
beforeEach(() => {
|
|
streamClaudeChatMock.mockClear()
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('useAIChat', () => {
|
|
const emptyContent: Record<string, string> = {}
|
|
|
|
it('sends first message without history', async () => {
|
|
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
|
|
|
act(() => { result.current.sendMessage('hello') })
|
|
|
|
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
|
const message = streamClaudeChatMock.mock.calls[0][0]
|
|
// First message: no history, so just the plain text
|
|
expect(message).toBe('hello')
|
|
})
|
|
|
|
it('includes conversation history in second message', async () => {
|
|
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
|
|
|
// Send first message
|
|
act(() => { result.current.sendMessage('What is Rust?') })
|
|
// Wait for mock response
|
|
await act(async () => { vi.advanceTimersByTime(50) })
|
|
|
|
// Now messages state has: [user: What is Rust?, assistant: mock response]
|
|
expect(result.current.messages).toHaveLength(2)
|
|
|
|
// Send second message
|
|
act(() => { result.current.sendMessage('Tell me more') })
|
|
|
|
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
|
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
|
|
// Should contain conversation history
|
|
expect(secondMessage).toContain('What is Rust?')
|
|
expect(secondMessage).toContain('mock response')
|
|
expect(secondMessage).toContain('Tell me more')
|
|
})
|
|
|
|
it('resets history on clearConversation', async () => {
|
|
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
|
|
|
// Send a message and get response
|
|
act(() => { result.current.sendMessage('hello') })
|
|
await act(async () => { vi.advanceTimersByTime(50) })
|
|
|
|
// Clear conversation
|
|
act(() => { result.current.clearConversation() })
|
|
expect(result.current.messages).toHaveLength(0)
|
|
|
|
// Send new message — should have no history
|
|
act(() => { result.current.sendMessage('fresh start') })
|
|
|
|
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
|
expect(lastCall[0]).toBe('fresh start')
|
|
})
|
|
|
|
it('accumulates multiple exchanges in history', async () => {
|
|
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
|
|
|
// Exchange 1
|
|
act(() => { result.current.sendMessage('Q1') })
|
|
await act(async () => { vi.advanceTimersByTime(50) })
|
|
|
|
// Exchange 2
|
|
act(() => { result.current.sendMessage('Q2') })
|
|
await act(async () => { vi.advanceTimersByTime(50) })
|
|
|
|
// Exchange 3
|
|
act(() => { result.current.sendMessage('Q3') })
|
|
|
|
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
|
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
|
|
// Should contain all prior exchanges
|
|
expect(thirdMessage).toContain('Q1')
|
|
expect(thirdMessage).toContain('Q2')
|
|
expect(thirdMessage).toContain('Q3')
|
|
})
|
|
|
|
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
|
|
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
|
|
|
// First message
|
|
act(() => { result.current.sendMessage('hello') })
|
|
await act(async () => { vi.advanceTimersByTime(50) })
|
|
|
|
// Second message — session_id should still be undefined (no --resume)
|
|
act(() => { result.current.sendMessage('follow up') })
|
|
|
|
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
|
// Third argument is sessionId — must be undefined for both calls
|
|
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
|
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
|
|
})
|
|
})
|