fix: include full conversation history in AI chat requests

- Add trimHistory() to keep most recent messages within 100k token budget
- Add formatMessageWithHistory() to prepend conversation context to each message
- Wire up in useAIChat.ts: sendMessage now includes full history
- Keep --resume as belt-and-suspenders for same-session continuity
- 14 new tests in ai-chat.test.ts and useAIChat.test.ts
This commit is contained in:
lucaronin
2026-03-06 23:24:07 +01:00
parent 7610f1b89a
commit 21bfaa8c30
4 changed files with 250 additions and 2 deletions

View File

@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
import {
estimateTokens, buildSystemPrompt,
nextMessageId, checkClaudeCli, streamClaudeChat,
trimHistory, formatMessageWithHistory,
type ChatMessage, MAX_HISTORY_TOKENS,
} from './ai-chat'
import type { VaultEntry } from '../types'
@@ -73,6 +75,107 @@ describe('checkClaudeCli', () => {
})
})
// --- trimHistory ---
describe('trimHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns empty array for empty history', () => {
expect(trimHistory([], 1000)).toEqual([])
})
it('returns all messages when under token limit', () => {
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
expect(trimHistory(history, 1000)).toEqual(history)
})
it('drops oldest messages when over token limit', () => {
const history = [
msg('user', 'a'.repeat(400)), // 100 tokens
msg('assistant', 'b'.repeat(400)), // 100 tokens
msg('user', 'c'.repeat(400)), // 100 tokens
]
const result = trimHistory(history, 200)
// Should keep the two most recent messages (200 tokens)
expect(result).toHaveLength(2)
expect(result[0].content).toBe('b'.repeat(400))
expect(result[1].content).toBe('c'.repeat(400))
})
it('keeps at least one message if it fits', () => {
const history = [
msg('user', 'a'.repeat(2000)), // 500 tokens
msg('assistant', 'b'.repeat(80)), // 20 tokens
]
const result = trimHistory(history, 30)
expect(result).toHaveLength(1)
expect(result[0].content).toBe('b'.repeat(80))
})
it('returns empty when single message exceeds limit', () => {
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
expect(trimHistory(history, 10)).toEqual([])
})
})
// --- formatMessageWithHistory ---
describe('formatMessageWithHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns bare message when no history', () => {
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
})
it('includes conversation history before the new message', () => {
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
expect(result).toContain('What is Rust?')
expect(result).toContain('A systems language.')
expect(result).toContain('How does it compare to Go?')
})
it('labels user and assistant messages correctly', () => {
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
const result = formatMessageWithHistory(history, 'Q2')
expect(result).toContain('[user]: Q1')
expect(result).toContain('[assistant]: A1')
expect(result).toContain('[user]: Q2')
})
it('preserves message order', () => {
const history = [
msg('user', 'first'),
msg('assistant', 'second'),
msg('user', 'third'),
msg('assistant', 'fourth'),
]
const result = formatMessageWithHistory(history, 'fifth')
const firstIdx = result.indexOf('first')
const secondIdx = result.indexOf('second')
const thirdIdx = result.indexOf('third')
const fourthIdx = result.indexOf('fourth')
const fifthIdx = result.indexOf('fifth')
expect(firstIdx).toBeLessThan(secondIdx)
expect(secondIdx).toBeLessThan(thirdIdx)
expect(thirdIdx).toBeLessThan(fourthIdx)
expect(fourthIdx).toBeLessThan(fifthIdx)
})
})
// --- MAX_HISTORY_TOKENS ---
describe('MAX_HISTORY_TOKENS', () => {
it('is a reasonable token limit', () => {
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
})
})
// --- streamClaudeChat ---
describe('streamClaudeChat', () => {

View File

@@ -76,6 +76,34 @@ export function nextMessageId(): string {
return `msg-${++msgIdCounter}-${Date.now()}`
}
// --- Conversation history ---
/** Max tokens of history to include in each request. */
export const MAX_HISTORY_TOKENS = 100_000
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
let tokenCount = 0
const result: ChatMessage[] = []
for (let i = history.length - 1; i >= 0; i--) {
const tokens = estimateTokens(history[i].content)
if (tokenCount + tokens > maxTokens) break
result.unshift(history[i])
tokenCount += tokens
}
return result
}
/** Format conversation history + new message into a single prompt for the CLI. */
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
if (history.length === 0) return newMessage
const lines = history.map(m => `[${m.role}]: ${m.content}`)
lines.push(`[user]: ${newMessage}`)
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
}
// --- Claude CLI status ---
export interface ClaudeCliStatus {