fix: resolve AI chat empty body via || fallback + defensive body + Rust strip_frontmatter

Three root causes identified and fixed:

1. JS semantics bug: `activeNoteContent ?? allContent[path]` used nullish
   coalescing (`??`) which does NOT fall through on empty string ''. When
   handleEditorChange temporarily overwrites tab.content with frontmatter-
   only content during async content swaps, activeNoteContent becomes ''
   and the fallback to allContent never triggers. Fix: change `??` to `||`.

2. Defence-in-depth: when body is still empty after fallback (Tauri mode
   where allContent is {}), but wordCount > 0, the body field now includes
   an explicit get_note instruction instead of being empty. This is more
   reliable than the preamble instruction that Claude may skip.

3. Rust strip_frontmatter used `rest.find("---")` which matches `---`
   anywhere in text (including inside frontmatter values like `title:
   foo---bar`). Fixed to use `rest.find("\n---")` for line-boundary
   matching. This ensures accurate wordCount for the fallback heuristic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-08 17:58:47 +01:00
parent 6575ec2d1c
commit 2249c4a450
3 changed files with 91 additions and 4 deletions

View File

@@ -391,6 +391,37 @@ describe('buildContextSnapshot', () => {
expect(json.activeNote.body).toBe('# Just a heading\n\nSome plain content.')
})
it('falls back to allContent when activeNoteContent is empty string (|| fix)', () => {
// Regression: `??` does not fall through on empty string '', only on null/undefined.
// The `||` fix ensures empty string falls through to allContent.
const result = buildContextSnapshot({
activeEntry: active,
allContent,
entries,
activeNoteContent: '',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toContain('Project content.')
expect(json.activeNote.body).not.toBe('')
})
it('includes defensive body when body is empty but wordCount > 0', () => {
// When body is empty (e.g. timing issue) but note has content on disk,
// body field should instruct Claude to use get_note
const entryWithWords = makeEntry({
path: '/vault/a.md', title: 'Alpha', wordCount: 206,
})
const result = buildContextSnapshot({
activeEntry: entryWithWords,
allContent: {},
entries,
activeNoteContent: '---\ntitle: Alpha\n---\n',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toContain('get_note')
expect(json.activeNote.body).toContain('206 words')
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('[[Note Title]]')

View File

@@ -105,8 +105,18 @@ const MAX_NOTE_LIST_ITEMS = 100
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const rawContent = activeNoteContent ?? allContent[activeEntry.path] ?? ''
const body = extractBody(rawContent)
// Use `||` (not `??`) so empty string '' falls through to allContent.
// This handles the case where handleEditorChange temporarily overwrites
// tab.content with frontmatter-only content during async content swaps.
const rawContent = activeNoteContent || allContent[activeEntry.path] || ''
let body = extractBody(rawContent)
// Defence-in-depth: when body is empty but the note has content on disk,
// include an explicit instruction in the body field itself (more reliable
// than a preamble instruction that Claude might skip).
if (!body && activeEntry.wordCount > 0) {
body = `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
}
const snapshot: Record<string, unknown> = {
activeNote: {