Compare commits

..

3 Commits

Author SHA1 Message Date
Test
a6d60695a2 style: rustfmt formatting fix in cache test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:22:33 +01:00
Test
7ddc0c14bf fix: add visible key to frontmatterToEntryPatch maps
The ENTRY_DELETE_MAP and update map in frontmatterToEntryPatch were
missing the 'visible' key. When handleDeleteProperty or
handleUpdateFrontmatter was called for 'visible', the in-memory
VaultEntry was not updated, causing the sidebar to stay stale even
after the file on disk was correctly modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:20:51 +01:00
Test
2a00b8aac7 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
2026-03-06 23:47:59 +01:00
8 changed files with 316 additions and 5 deletions

View File

@@ -646,4 +646,63 @@ mod tests {
assert!(titles.contains(&"Default Theme"));
assert!(titles.contains(&"Dark Theme"));
}
#[test]
fn test_update_same_commit_visible_removed_from_type_note() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// Commit a type note with visible: false
create_test_file(
vault,
"type/topic.md",
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
);
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime the cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].visible,
Some(false),
"visible must be false initially"
);
// User removes visible field (uncommitted edit)
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
// Reload — must reflect the removal (visible defaults to None)
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(
entries2[0].visible, None,
"visible must be None after removing the field"
);
}
}

114
src/hooks/useAIChat.test.ts Normal file
View File

@@ -0,0 +1,114 @@
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')
})
})

View File

@@ -7,6 +7,7 @@ import type { VaultEntry } from '../types'
import {
type ChatMessage, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
export function useAIChat(
@@ -29,9 +30,11 @@ export function useAIChat(
abortRef.current = false
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
onInit: (sid) => { sessionIdRef.current = sid },
onText: (chunk) => {
@@ -58,7 +61,7 @@ export function useAIChat(
}).then(sid => {
if (sid) sessionIdRef.current = sid
})
}, [isStreaming, allContent, contextNotes])
}, [isStreaming, allContent, contextNotes, messages])
const clearConversation = useCallback(() => {
abortRef.current = true

View File

@@ -25,8 +25,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
...overrides,

View File

@@ -290,6 +290,8 @@ describe('frontmatterToEntryPatch', () => {
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
['visible', false, { visible: false }],
['visible', true, { visible: null }],
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
@@ -321,6 +323,7 @@ describe('frontmatterToEntryPatch', () => {
['archived', { archived: false }],
['order', { order: null }],
['template', { template: null }],
['visible', { visible: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {

View File

@@ -150,7 +150,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
template: { template: null }, sort: { sort: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -170,6 +170,7 @@ export function frontmatterToEntryPatch(
template: { template: str },
sort: { sort: str },
view: { view: str },
visible: { visible: value === false ? false : null },
}
return updates[k] ?? {}
}

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 {