fix: AI chat wikilinks — system prompt + integration tests

Root cause: buildContextSnapshot (the system prompt used when a note is
active — the common case) did NOT instruct the AI to use [[wikilinks]].
Only buildAgentSystemPrompt (the fallback when no context) had it.
So the AI almost never produced clickable wikilinks.

Fix: Add the [[Note Title]] wikilink instruction to
buildContextSnapshot's preamble.

The click handler chain was already correct (verified with new
integration tests): MarkdownContent → AiMessage → AiPanel →
notes.handleNavigateWikilink → findWikilinkTarget → handleSelectNote.

Tests added:
- Unit: buildContextSnapshot includes wikilink instruction
- Integration: clicking wikilinks in AiPanel calls onOpenNote
- Playwright: wikilink renders, click opens note in tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-07 13:42:32 +01:00
parent fe2cef092a
commit 35afd09364
4 changed files with 62 additions and 7 deletions

View File

@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
vi.mock('../hooks/useAiAgent', () => ({
useAiAgent: () => ({
messages: [],
status: 'idle',
messages: mockMessages,
status: mockStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
}),
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
})
describe('AiPanel', () => {
beforeEach(() => {
mockMessages = []
mockStatus = 'idle'
})
it('renders panel with AI Chat header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
@@ -162,4 +169,41 @@ describe('AiPanel', () => {
fireEvent.keyDown(panel, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
mockMessages = [{
userMessage: 'Tell me about notes',
actions: [],
response: 'Check out [[Build Laputa App]] for details.',
id: 'msg-1',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('Build Laputa App')
fireEvent.click(wikilink!)
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
})
it('renders wikilinks with special characters and clicking works', () => {
mockMessages = [{
userMessage: 'Tell me about meetings',
actions: [],
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
id: 'msg-2',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
fireEvent.click(wikilinks[0])
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
fireEvent.click(wikilinks[1])
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
})
})

View File

@@ -331,6 +331,12 @@ describe('buildContextSnapshot', () => {
expect(json.noteList).toBeUndefined()
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('[[Note Title]]')
expect(result).toContain('wikilink')
})
it('includes belongsTo and relatedTo in frontmatter', () => {
const entryWithRels = makeEntry({
path: '/vault/a.md', title: 'Alpha',

View File

@@ -152,6 +152,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``

View File

@@ -48,15 +48,19 @@ test.describe('AI chat wikilink rendering', () => {
})
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
const wikilink = page.locator('.chat-wikilink').first()
await expect(wikilink).toHaveText('Build Laputa App')
// Click the second wikilink ("Matteo Cellini") which is NOT already open in a tab
const wikilink = page.locator('.chat-wikilink').nth(1)
await expect(wikilink).toHaveText('Matteo Cellini')
// Verify "Matteo Cellini" is not yet in any tab
const tabsBefore = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
// Click the wikilink
await wikilink.click()
await page.waitForTimeout(500)
// Verify the note opened in a tab — the editor breadcrumb shows the active note title
const breadcrumb = page.locator('.app__editor span.truncate.font-medium', { hasText: 'Build Laputa App' })
await expect(breadcrumb).toBeVisible({ timeout: 3000 })
// Verify a new tab appeared with the note title
const tabsAfter = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
expect(tabsAfter).toBeGreaterThan(tabsBefore)
})
})