From 2249c4a45078fa6ef6ab6a7f9f3cd7b7172d7bdc Mon Sep 17 00:00:00 2001 From: Test Date: Sun, 8 Mar 2026 17:58:47 +0100 Subject: [PATCH] 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 --- src-tauri/src/vault/parsing.rs | 50 ++++++++++++++++++++++++++++++++-- src/utils/ai-context.test.ts | 31 +++++++++++++++++++++ src/utils/ai-context.ts | 14 ++++++++-- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/vault/parsing.rs b/src-tauri/src/vault/parsing.rs index b89c372b..8f9193fa 100644 --- a/src-tauri/src/vault/parsing.rs +++ b/src-tauri/src/vault/parsing.rs @@ -18,12 +18,18 @@ pub(super) fn extract_title(content: &str, filename: &str) -> String { } /// Remove YAML frontmatter (triple-dash delimited) from content. +/// The closing `---` must appear at the start of a line to avoid matching +/// occurrences inside frontmatter values (e.g. `title: foo---bar`). fn strip_frontmatter(content: &str) -> &str { let Some(rest) = content.strip_prefix("---") else { return content; }; - match rest.find("---") { - Some(end) => rest[end + 3..].trim_start(), + // Find closing `---` at the start of a line (preceded by newline) + match rest.find("\n---") { + Some(end) => { + let after = end + 4; // skip past "\n---" + rest[after..].trim_start() + } None => content, } } @@ -384,6 +390,46 @@ mod tests { assert_eq!(count_body_words(content), 6); } + // --- strip_frontmatter tests --- + + #[test] + fn test_strip_frontmatter_basic() { + let content = "---\ntitle: Test\n---\nBody content."; + assert_eq!(strip_frontmatter(content), "Body content."); + } + + #[test] + fn test_strip_frontmatter_no_frontmatter() { + let content = "Just plain content."; + assert_eq!(strip_frontmatter(content), "Just plain content."); + } + + #[test] + fn test_strip_frontmatter_dashes_in_value() { + // The closing --- must be at line start, not inside a value + let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here."; + assert_eq!(strip_frontmatter(content), "Body here."); + } + + #[test] + fn test_strip_frontmatter_unclosed() { + let content = "---\ntitle: Test\nNo closing fence"; + assert_eq!(strip_frontmatter(content), content); + } + + #[test] + fn test_strip_frontmatter_empty_body() { + let content = "---\ntitle: Test\n---\n"; + assert_eq!(strip_frontmatter(content), ""); + } + + #[test] + fn test_count_body_words_with_dashes_in_frontmatter_value() { + // Regression: strip_frontmatter previously matched --- inside values + let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words."; + assert_eq!(count_body_words(content), 3); + } + // --- strip_markdown_chars tests --- #[test] diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index e27e5b8c..ca26b81d 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -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]]') diff --git a/src/utils/ai-context.ts b/src/utils/ai-context.ts index 4b673a3e..832fffa0 100644 --- a/src/utils/ai-context.ts +++ b/src/utils/ai-context.ts @@ -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 = { activeNote: {