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:
lucaronin
2026-03-07 13:11:34 +01:00
parent e29360ef0a
commit fe2cef092a
2 changed files with 124 additions and 65 deletions

View File

@@ -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
})
})

View File

@@ -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])