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

@@ -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]

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: {