From fe2cef092a6a70fa46fae93a26a652c769160cc3 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 7 Mar 2026 13:11:34 +0100 Subject: [PATCH] fix: use --resume for AI chat conversation continuity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach embedded conversation history as formatted text in the prompt ( 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 --- src/hooks/useAIChat.test.ts | 93 +++++++++++++++++++++++------------ src/hooks/useAIChat.ts | 96 ++++++++++++++++++++++++------------- 2 files changed, 124 insertions(+), 65 deletions(-) diff --git a/src/hooks/useAIChat.test.ts b/src/hooks/useAIChat.test.ts index 4e29fa03..d95ddeb7 100644 --- a/src/hooks/useAIChat.test.ts +++ b/src/hooks/useAIChat.test.ts @@ -13,14 +13,14 @@ vi.mock('../utils/ai-chat', async () => { ...actual, streamClaudeChat: (...args: Parameters) => { 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 = {} - 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 }) }) diff --git a/src/hooks/useAIChat.ts b/src/hooks/useAIChat.ts index 49839c92..e591845c 100644 --- a/src/hooks/useAIChat.ts +++ b/src/hooks/useAIChat.ts @@ -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 + sessionIdRef: React.MutableRefObject + setMessages: React.Dispatch> + setStreamingContent: React.Dispatch> + setIsStreaming: React.Dispatch> +} + +/** 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, 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(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])