fix: use --resume for AI chat conversation continuity
The previous approach embedded conversation history as formatted text in the prompt (<conversation_history> tags). The claude CLI's -p flag treats this as a single user turn, losing turn boundaries — so the model had no real multi-turn context. Switch to using --resume with the session_id returned by the CLI's init event. This lets the CLI manage conversation state natively: - First message starts a new session (with system prompt) - Subsequent messages resume via --resume (no system prompt needed) - Clear and retry reset the session_id for a fresh start Extract makeStreamCallbacks() to keep useAIChat complexity below CodeScene threshold. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,14 +13,14 @@ vi.mock('../utils/ai-chat', async () => {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: call onDone after a tick
|
||||
// Simulate async: emit Init with session_id, then text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('test-session')
|
||||
callbacks.onInit?.('session-001')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('test-session')
|
||||
return Promise.resolve('session-001')
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -39,40 +39,42 @@ afterEach(() => {
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without history', async () => {
|
||||
it('sends first message without session_id (new session)', 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
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes conversation history in second message', async () => {
|
||||
it('resumes session on second message via --resume', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response
|
||||
// Wait for mock response (which fires onInit with session-001)
|
||||
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
|
||||
// Send second message — should resume with session_id
|
||||
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')
|
||||
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
|
||||
// Raw message only (no embedded history)
|
||||
expect(message).toBe('Tell me more')
|
||||
// Session resumed via --resume
|
||||
expect(sessionId).toBe('session-001')
|
||||
// System prompt omitted on resumed sessions
|
||||
expect(systemPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets history on clearConversation', async () => {
|
||||
it('resets session on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
@@ -83,14 +85,15 @@ describe('useAIChat', () => {
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should have no history
|
||||
// Send new message — should start a fresh session (no session_id)
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id
|
||||
})
|
||||
|
||||
it('accumulates multiple exchanges in history', async () => {
|
||||
it('resumes session across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
@@ -105,26 +108,54 @@ describe('useAIChat', () => {
|
||||
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')
|
||||
|
||||
// First call: no session
|
||||
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
||||
// Second and third calls: resume session
|
||||
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
|
||||
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
|
||||
|
||||
// All messages are raw text (no embedded history)
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
|
||||
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
|
||||
})
|
||||
|
||||
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
it('includes system prompt only on first message of a session', async () => {
|
||||
const content = { 'note.md': 'Some note content' }
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
// First message
|
||||
const { result } = renderHook(() => useAIChat(content, notes))
|
||||
|
||||
// First message — system prompt included
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — session_id should still be undefined (no --resume)
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
// Second message — system prompt omitted (session already has it)
|
||||
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()
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets session on retry', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
// Should start a fresh session
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
|
||||
expect(lastCall[0]).toBe('hello') // re-sends the user message
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity uses the CLI's --resume flag: the first message
|
||||
* starts a new session; subsequent messages resume it via session_id.
|
||||
* This avoids embedding history as text in the prompt (which the CLI's
|
||||
* -p mode treats as a single user turn, losing turn boundaries).
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
sessionIdRef: React.MutableRefObject<string | undefined>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** Create stream callbacks that accumulate text and update React state. */
|
||||
function makeStreamCallbacks(
|
||||
refs: ChatStreamRefs,
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onInit: (sid) => { refs.sessionIdRef.current = sid },
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
refs.setStreamingContent(accumulated)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (refs.abortRef.current) return
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
onDone: () => {
|
||||
if (refs.abortRef.current) return
|
||||
if (accumulated) {
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
}
|
||||
return { callbacks, getAccumulated: () => accumulated }
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
@@ -18,54 +60,39 @@ export function useAIChat(
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
|
||||
const messageWithHistory = formatMessageWithHistory(history, text.trim())
|
||||
let accumulated = ''
|
||||
const currentSessionId = sessionIdRef.current
|
||||
// System prompt only on first message (new session).
|
||||
const systemPrompt = currentSessionId
|
||||
? undefined
|
||||
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
|
||||
|
||||
// No session_id: each call is independent. Context is provided via
|
||||
// formatted history in the prompt, avoiding --resume which causes
|
||||
// double-context confusion when combined with in-prompt history.
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
|
||||
onDone: () => {
|
||||
if (abortRef.current) return
|
||||
if (accumulated) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
|
||||
.then((sid) => {
|
||||
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
|
||||
})
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
@@ -74,6 +101,7 @@ export function useAIChat(
|
||||
const userMsg = messages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
sessionIdRef.current = undefined
|
||||
setMessages(prev => prev.slice(0, msgIndex))
|
||||
sendMessage(userMsg.content)
|
||||
}, [messages, sendMessage])
|
||||
|
||||
Reference in New Issue
Block a user