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:
@@ -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]
|
||||
|
||||
@@ -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]]')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user