Skip to main content

vtcode_core/prompts/
few_shot.rs

1//! Minimal few-shot example infrastructure.
2//!
3//! Implements a keyword-based selector with token-budget enforcement
4//! per Section 18.3.3 of the agentic-AI guide:
5//!
6//! > Few-shot examples improve reliability but consume tokens. The harness
7//! > should select relevant examples using embedding similarity to the
8//! > current query, rotate examples to avoid overfitting, budget examples
9//! > within the model allocation, and cache embeddings of the example
10//! > library to avoid recomputation.
11//!
12//! This module ships the keyword-based selector (no embedding provider
13//! dependency) and the token-budget enforcement (via
14//! [`vtcode_commons::tokens::estimate_tokens`]). Embedding-based selection
15//! and embedding-cache can layer on top later without changing the API.
16//!
17//! ## Example file format
18//!
19//! Examples live under `<workspace>/.vtcode/prompts/examples/*.md` or
20//! the canonical user config prompts directory's `examples/*.md`. The filename stem is the
21//! stable id; the file body uses YAML frontmatter for metadata:
22//!
23//! ```markdown
24//! ---
25//! id: read-then-edit-large-file
26//! tags: [read, edit, large-file, patch]
27//! summary: Read a large file in chunks, then patch with apply_patch.
28//! ---
29//! # User
30//! Refactor src/foo.rs to use the new API.
31//!
32//! # Assistant
33//! (the example body showing expected tool sequence)
34//! ```
35//!
36//! Both `tags` and `summary` are optional; the loader derives sensible
37//! defaults when they are absent.
38
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, OnceLock};
42
43use compact_str::CompactString;
44use serde::Deserialize;
45use tracing::warn;
46use vtcode_commons::tokens::estimate_tokens;
47
48use super::resource_cache::{ResourceCache, canonical_cache_path, fingerprint_markdown_directories};
49
50const EXAMPLES_DIR: &str = "examples";
51const PROMPTS_PARENT: &str = ".vtcode/prompts";
52const MIN_TOKEN_BUDGET: usize = 16;
53
54fn user_examples_dir(home: &Path) -> PathBuf {
55    super::resources::user_prompts_dir(home).join(EXAMPLES_DIR)
56}
57
58/// Default token budget for the `[Few-Shot Examples]` block. ~10% of an 8K
59/// context window, leaving the remainder for the base prompt, tools,
60/// history, and the model's response.
61pub const DEFAULT_FEW_SHOT_BUDGET_TOKENS: usize = 800;
62
63/// First line of every rendered few-shot block. The runloop persists the
64/// block in history once per user turn and recognizes it by this header.
65pub const FEW_SHOT_SECTION_HEADER: &str = "[Few-Shot Examples]";
66
67/// A single few-shot example loaded from disk.
68///
69/// The `id` field is the filename stem (e.g. `read-then-edit-large-file`).
70/// `tags` drive keyword selection; `summary` is for debug logs; `body` is
71/// the raw markdown body that flows verbatim into the prompt. `token_count`
72/// is filled on load and refreshed lazily on first selection.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FewShotExample {
75    pub id: String,
76    pub tags: Vec<String>,
77    pub summary: String,
78    pub body: String,
79    pub token_count: usize,
80    pub source_path: PathBuf,
81}
82
83/// YAML frontmatter schema. All fields are optional so authoring mistakes
84/// fail soft (loader falls back to derived defaults).
85#[derive(Debug, Clone, Default, Deserialize)]
86struct FewShotFrontmatter {
87    #[serde(default)]
88    id: Option<String>,
89    #[serde(default)]
90    tags: Option<Vec<String>>,
91    #[serde(default)]
92    summary: Option<String>,
93}
94
95/// Library of few-shot examples discovered from disk.
96///
97/// Construct via [`FewShotStore::load`] to read from both the workspace
98/// `.vtcode/prompts/examples/` and the canonical user config prompts directory
99/// directories. The constructor is fail-soft: unreadable files or bad
100/// frontmatter are skipped with a warning, not a panic.
101#[derive(Debug, Clone, Default)]
102pub struct FewShotStore {
103    examples: Vec<FewShotExample>,
104    normalized_tags: Vec<Vec<CompactString>>,
105}
106
107impl FewShotStore {
108    /// Load all examples from the workspace + home directories. The
109    /// workspace directory takes precedence on `id` collision (the home
110    /// copy is skipped with a warning).
111    ///
112    /// Pass `workspace_root = Some("/path")` and `home_dir = None` to
113    /// load only the workspace; pass `home_dir = Some("/home/user")` to
114    /// also pull user-global examples.
115    ///
116    /// The returned store shares the cached allocation: warm loads bump a
117    /// refcount instead of cloning every example body.
118    pub fn load(workspace_root: Option<&Path>, home_dir: Option<&Path>) -> Arc<Self> {
119        let key = FewShotCacheKey::new(workspace_root, home_dir);
120        load_cached_sync(key, workspace_root.map(Path::to_path_buf), home_dir.map(Path::to_path_buf))
121    }
122
123    /// Load examples without performing cache-key or cache-miss filesystem
124    /// work on a Tokio worker. Warm loads still use the blocking pool because
125    /// canonicalizing a new source path may touch the filesystem.
126    pub async fn load_async(workspace_root: Option<&Path>, home_dir: Option<&Path>) -> Arc<Self> {
127        let workspace_root = workspace_root.map(Path::to_path_buf);
128        let home_dir = home_dir.map(Path::to_path_buf);
129        match tokio::task::spawn_blocking(move || {
130            // Canonicalization may touch the filesystem for a first-time
131            // source path, so keep key construction on the blocking pool too.
132            let key = FewShotCacheKey::new(workspace_root.as_deref(), home_dir.as_deref());
133            load_cached_sync(key, workspace_root, home_dir)
134        })
135        .await
136        {
137            Ok(store) => store,
138            Err(error) => {
139                warn!(%error, "few-shot resource load task failed");
140                Arc::new(Self::default())
141            }
142        }
143    }
144
145    /// Construct a store from an in-memory list. Useful for tests and for
146    /// loading examples that come from somewhere other than disk.
147    pub fn from_examples(examples: Vec<FewShotExample>) -> Self {
148        let mut sorted = examples;
149        sorted.sort_by(|a, b| a.id.cmp(&b.id));
150        let normalized_tags = sorted.iter().map(|example| normalize_tags(&example.tags)).collect();
151        Self { examples: sorted, normalized_tags }
152    }
153
154    /// Number of loaded examples.
155    pub fn len(&self) -> usize {
156        self.examples.len()
157    }
158
159    pub fn is_empty(&self) -> bool {
160        self.examples.is_empty()
161    }
162
163    /// Iterate over all loaded examples in deterministic id order.
164    pub fn iter(&self) -> impl Iterator<Item = &FewShotExample> {
165        self.examples.iter()
166    }
167
168    /// Select examples whose tags overlap with the query, capped at
169    /// `budget_tokens`. Returns examples in score-descending order
170    /// (ties broken by id ascending).
171    ///
172    /// Scoring rule:
173    /// - +1.0 for each exact match between a tag and a query word
174    /// - +0.5 for each tag that appears as a substring of the query
175    ///
176    /// `budget_tokens` of 0 (or any value smaller than the smallest
177    /// example) returns an empty selection. Examples whose token_count
178    /// alone exceeds the budget are skipped.
179    pub fn select(&self, query: &str, budget_tokens: usize) -> Vec<&FewShotExample> {
180        if self.examples.is_empty() || budget_tokens < MIN_TOKEN_BUDGET {
181            return Vec::new();
182        }
183
184        let query_words = tokenize(query);
185        if query_words.is_empty() {
186            return Vec::new();
187        }
188        let query_lower = query.to_ascii_lowercase();
189
190        let mut scored: Vec<(f64, &FewShotExample)> = self
191            .examples
192            .iter()
193            .zip(&self.normalized_tags)
194            .filter_map(|(example, normalized_tags)| {
195                let score = score_example(normalized_tags, &query_words, &query_lower);
196                if score <= 0.0 {
197                    return None;
198                }
199                Some((score, example))
200            })
201            .collect();
202
203        // Stable ordering: score desc, then id asc.
204        scored.sort_by(|a, b| {
205            b.0.partial_cmp(&a.0)
206                .unwrap_or(std::cmp::Ordering::Equal)
207                .then_with(|| a.1.id.cmp(&b.1.id))
208        });
209
210        let mut chosen: Vec<&FewShotExample> = Vec::new();
211        let mut used = 0usize;
212        for (_, example) in scored {
213            if example.token_count == 0 {
214                // Defensive: skip examples that failed to size on load.
215                continue;
216            }
217            if used.saturating_add(example.token_count) > budget_tokens {
218                continue;
219            }
220            chosen.push(example);
221            used = used.saturating_add(example.token_count);
222        }
223
224        chosen
225    }
226}
227
228/// Render selected examples into the prompt section body. Returns an
229/// empty string when `examples` is empty so callers can `writeln!` it
230/// unconditionally.
231pub fn render_few_shot_section(examples: &[&FewShotExample]) -> String {
232    if examples.is_empty() {
233        return String::new();
234    }
235
236    let mut out = String::new();
237    out.push_str(FEW_SHOT_SECTION_HEADER);
238    out.push_str("\nThe following examples illustrate expected behavior for similar tasks.\n\n");
239    for example in examples {
240        let _ = std::fmt::Write::write_fmt(&mut out, format_args!("### {}\n", example.id));
241        if !example.summary.is_empty() {
242            let _ = std::fmt::Write::write_fmt(&mut out, format_args!("_{}_\n\n", example.summary));
243        }
244        let _ = std::fmt::Write::write_fmt(&mut out, format_args!("{}\n\n", example.body.trim_end()));
245    }
246    out
247}
248
249// ---------------------------------------------------------------------------
250// Internals
251// ---------------------------------------------------------------------------
252
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254struct FewShotCacheKey {
255    workspace_examples_dir: Option<PathBuf>,
256    home_examples_dir: Option<PathBuf>,
257}
258
259impl FewShotCacheKey {
260    fn new(workspace_root: Option<&Path>, home_dir: Option<&Path>) -> Self {
261        Self {
262            workspace_examples_dir: workspace_root
263                .map(|root| canonical_cache_path(&root.join(PROMPTS_PARENT).join(EXAMPLES_DIR))),
264            home_examples_dir: home_dir.map(|home| canonical_cache_path(&user_examples_dir(home))),
265        }
266    }
267}
268
269static FEW_SHOT_CACHE: OnceLock<ResourceCache<FewShotCacheKey, FewShotStore>> = OnceLock::new();
270
271fn few_shot_cache() -> &'static ResourceCache<FewShotCacheKey, FewShotStore> {
272    FEW_SHOT_CACHE.get_or_init(ResourceCache::default)
273}
274
275fn load_cached_sync(
276    key: FewShotCacheKey,
277    workspace_root: Option<PathBuf>,
278    home_dir: Option<PathBuf>,
279) -> Arc<FewShotStore> {
280    if let Some(cached) = few_shot_cache().fast_get(&key) {
281        return cached;
282    }
283
284    few_shot_cache().with_load_gate(|| {
285        if let Some(cached) = few_shot_cache().fast_get(&key) {
286            return cached;
287        }
288
289        let source_dirs = key
290            .workspace_examples_dir
291            .iter()
292            .chain(key.home_examples_dir.iter())
293            .map(PathBuf::as_path)
294            .collect::<Vec<_>>();
295        let fingerprint = fingerprint_markdown_directories(&source_dirs);
296        if let Some(cached) = few_shot_cache().get_if_unchanged(&key, &fingerprint) {
297            return cached;
298        }
299
300        let store = load_uncached(workspace_root.as_deref(), home_dir.as_deref());
301        few_shot_cache().insert(key, store.clone(), fingerprint);
302        Arc::new(store)
303    })
304}
305
306fn load_uncached(workspace_root: Option<&Path>, home_dir: Option<&Path>) -> FewShotStore {
307    let mut by_id: HashMap<String, FewShotExample> = HashMap::new();
308
309    // Keep the workspace copy when an id collides with a user-global copy.
310    if let Some(workspace) = workspace_root {
311        merge_from_dir(&mut by_id, &workspace.join(PROMPTS_PARENT).join(EXAMPLES_DIR));
312    }
313
314    if let Some(home) = home_dir {
315        merge_from_dir(&mut by_id, &user_examples_dir(home));
316    }
317
318    FewShotStore::from_examples(by_id.into_values().collect())
319}
320
321fn merge_from_dir(by_id: &mut HashMap<String, FewShotExample>, dir: &Path) {
322    let entries = match std::fs::read_dir(dir) {
323        Ok(entries) => entries,
324        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
325        Err(err) => {
326            warn!("few_shot: failed to read directory {}: {err}", dir.display());
327            return;
328        }
329    };
330
331    let mut paths: Vec<PathBuf> = entries
332        .filter_map(Result::ok)
333        .map(|entry| entry.path())
334        .filter(|path| {
335            path.is_file()
336                && path
337                    .extension()
338                    .and_then(|ext| ext.to_str())
339                    .map(|ext| ext.eq_ignore_ascii_case("md"))
340                    .unwrap_or(false)
341        })
342        .collect();
343    paths.sort();
344
345    for path in paths {
346        let Some(stem) = path
347            .file_stem()
348            .and_then(|s| s.to_str())
349            .map(str::trim)
350            .filter(|s| !s.is_empty())
351        else {
352            continue;
353        };
354
355        let Ok(raw) = std::fs::read_to_string(&path) else {
356            warn!("few_shot: could not read {}", path.display());
357            continue;
358        };
359
360        if let Some(existing) = by_id.get(stem) {
361            warn!(
362                "few_shot: duplicate id '{stem}' ({}); keeping earlier {}",
363                path.display(),
364                existing.source_path.display()
365            );
366            continue;
367        }
368
369        match parse_example(stem, &raw, &path) {
370            Ok(example) => {
371                by_id.insert(stem.to_string(), example);
372            }
373            Err(err) => {
374                warn!("few_shot: failed to parse {}: {err}", path.display());
375            }
376        }
377    }
378}
379
380fn parse_example(fallback_id: &str, raw: &str, source_path: &Path) -> Result<FewShotExample, String> {
381    let (frontmatter, body) = parse_frontmatter(raw);
382    let id = frontmatter
383        .id
384        .map(|value| value.trim().to_string())
385        .filter(|value| !value.is_empty())
386        .unwrap_or_else(|| fallback_id.to_string());
387    let tags = frontmatter.tags.unwrap_or_default();
388    let summary = frontmatter.summary.map(|value| value.trim().to_string()).unwrap_or_default();
389    let body = body.trim().to_string();
390    let token_count = estimate_tokens(&body);
391
392    Ok(FewShotExample {
393        id,
394        tags,
395        summary,
396        body,
397        token_count,
398        source_path: source_path.to_path_buf(),
399    })
400}
401
402fn parse_frontmatter(raw: &str) -> (FewShotFrontmatter, String) {
403    let trimmed = raw.trim_start_matches('\u{feff}');
404    if !trimmed.starts_with("---\n") {
405        return (FewShotFrontmatter::default(), raw.to_string());
406    }
407    let Some(end_rel) = trimmed[4..].find("\n---") else {
408        return (FewShotFrontmatter::default(), raw.to_string());
409    };
410    let yaml = &trimmed[4..4 + end_rel];
411    let body_start = 4 + end_rel + "\n---".len();
412    let body = trimmed[body_start..].trim_start_matches('\n').to_string();
413
414    let frontmatter = match serde_saphyr::from_str::<FewShotFrontmatter>(yaml) {
415        Ok(value) => value,
416        Err(err) => {
417            warn!("few_shot: frontmatter parse failed: {err}");
418            FewShotFrontmatter::default()
419        }
420    };
421
422    (frontmatter, body)
423}
424
425fn tokenize(query: &str) -> Vec<String> {
426    query
427        .split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
428        .filter(|token| !token.is_empty())
429        .map(|token| token.to_ascii_lowercase())
430        .collect()
431}
432
433fn normalize_tags(tags: &[String]) -> Vec<CompactString> {
434    tags.iter().map(|tag| CompactString::from(tag.to_ascii_lowercase())).collect()
435}
436
437fn score_example(normalized_tags: &[CompactString], query_words: &[String], query_lower: &str) -> f64 {
438    let mut score = 0.0;
439    for tag in normalized_tags {
440        if query_words.iter().any(|word| word == tag.as_str()) {
441            score += 1.0;
442            continue;
443        }
444        if query_lower.contains(tag.as_str()) {
445            score += 0.5;
446        }
447    }
448    score
449}
450
451// ---------------------------------------------------------------------------
452// Tests
453// ---------------------------------------------------------------------------
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::path::PathBuf;
459
460    fn example(id: &str, tags: &[&str], body: &str) -> FewShotExample {
461        let token_count = estimate_tokens(body);
462        FewShotExample {
463            id: id.to_string(),
464            tags: tags.iter().map(|s| s.to_string()).collect(),
465            summary: String::new(),
466            body: body.to_string(),
467            token_count,
468            source_path: PathBuf::from(format!("/tmp/{id}.md")),
469        }
470    }
471
472    #[test]
473    fn empty_store_returns_empty_selection() {
474        let store = FewShotStore::default();
475        assert!(store.select("anything", 1000).is_empty());
476    }
477
478    #[test]
479    fn zero_budget_returns_empty() {
480        let store = FewShotStore::from_examples(vec![example("edit", &["edit"], "do an edit")]);
481        assert!(store.select("edit a file", 0).is_empty());
482        assert!(store.select("edit a file", 8).is_empty());
483    }
484
485    #[test]
486    fn matching_tag_is_selected() {
487        let store = FewShotStore::from_examples(vec![
488            example("edit", &["edit"], "an edit example"),
489            example("search", &["search"], "a search example"),
490        ]);
491        let chosen = store.select("please edit foo.rs", 1000);
492        assert_eq!(chosen.len(), 1);
493        assert_eq!(chosen[0].id, "edit");
494    }
495
496    #[test]
497    fn no_overlap_returns_empty() {
498        let store = FewShotStore::from_examples(vec![example("deploy", &["deploy", "kubernetes"], "a deploy example")]);
499        let chosen = store.select("read a local file", 1000);
500        assert!(chosen.is_empty());
501    }
502
503    #[test]
504    fn example_exceeding_budget_is_skipped() {
505        // Two examples, both 50 tokens. Budget = 60 keeps only one.
506        let store = FewShotStore::from_examples(vec![
507            example("a", &["foo"], &"foo ".repeat(50)),
508            example("b", &["foo"], &"foo ".repeat(50)),
509        ]);
510        let chosen = store.select("foo bar", 60);
511        assert_eq!(chosen.len(), 1);
512        assert_eq!(chosen[0].id, "a"); // id-asc tiebreak
513    }
514
515    #[test]
516    fn multiple_matches_sorted_by_score_then_id() {
517        let store = FewShotStore::from_examples(vec![
518            // "git" matches as both a word and substring, plus "commit".
519            example("git-commit", &["git", "commit"], "git commit example"),
520            // Only one tag matches.
521            example("git-only", &["git"], "git only example"),
522            // Different id but same score as git-only.
523            example("zzz-other", &["git"], "git other example"),
524        ]);
525        let chosen = store.select("git commit message", 5000);
526        // git-commit scores highest (git + commit).
527        assert_eq!(chosen[0].id, "git-commit");
528        // The remaining two tie on score, sorted by id asc.
529        let rest: Vec<&str> = chosen.iter().skip(1).map(|e| e.id.as_str()).collect();
530        assert_eq!(rest, vec!["git-only", "zzz-other"]);
531    }
532
533    #[test]
534    fn parse_frontmatter_extracts_metadata() {
535        let raw = "---\nid: read-then-edit\ntags: [read, edit]\nsummary: read first\n---\nbody here\n";
536        let (fm, body) = parse_frontmatter(raw);
537        assert_eq!(fm.id.as_deref(), Some("read-then-edit"));
538        assert_eq!(fm.tags, Some(vec!["read".to_string(), "edit".to_string()]));
539        assert_eq!(fm.summary.as_deref(), Some("read first"));
540        // Body keeps a trailing newline (callers should .trim() if needed);
541        // parse_example does that for FewShotExample.body.
542        assert_eq!(body, "body here\n");
543    }
544
545    #[test]
546    fn parse_frontmatter_without_metadata_returns_body() {
547        let raw = "no frontmatter here\nbody text\n";
548        let (fm, body) = parse_frontmatter(raw);
549        assert!(fm.id.is_none());
550        assert_eq!(body, raw);
551    }
552
553    #[test]
554    fn render_section_is_empty_when_no_examples() {
555        assert!(render_few_shot_section(&[]).is_empty());
556    }
557
558    #[test]
559    fn render_section_includes_id_and_body() {
560        let store =
561            FewShotStore::from_examples(vec![example("demo", &["hi", "demo"], "user said hi\nassistant said hello")]);
562        let chosen = store.select("hi there", 1000);
563        let rendered = render_few_shot_section(&chosen);
564        assert!(rendered.starts_with(FEW_SHOT_SECTION_HEADER));
565        assert!(rendered.contains("[Few-Shot Examples]\nThe following examples illustrate"));
566        assert!(rendered.contains("### demo"));
567        assert!(rendered.contains("user said hi"));
568    }
569
570    #[test]
571    fn load_discovers_examples_from_disk() {
572        let workspace = tempfile::tempdir().expect("workspace");
573        let examples_dir = workspace.path().join(".vtcode/prompts/examples");
574        std::fs::create_dir_all(&examples_dir).expect("mkdir");
575        std::fs::write(examples_dir.join("demo.md"), "---\ntags: [demo]\nsummary: a demo\n---\nDemo body\n")
576            .expect("write");
577
578        let store = FewShotStore::load(Some(workspace.path()), None);
579        assert_eq!(store.len(), 1);
580        let only = &store.examples[0];
581        assert_eq!(only.id, "demo");
582        assert_eq!(only.tags, vec!["demo".to_string()]);
583        assert_eq!(only.summary, "a demo");
584        assert!(only.body.contains("Demo body"));
585        assert!(only.token_count > 0);
586    }
587
588    #[test]
589    fn load_silently_skips_missing_directories() {
590        // No directories exist; load must succeed with zero examples.
591        let store = FewShotStore::load(Some(Path::new("/does/not/exist/anywhere")), None);
592        assert!(store.is_empty());
593    }
594
595    #[test]
596    #[serial_test::serial]
597    fn warm_loads_share_the_cached_store_without_cloning() {
598        // Asymmetric: the cold load parses (its Arc is fresh), but consecutive
599        // warm loads must hand out the same allocation (ptr_eq), not a deep
600        // clone of every example body. The per-turn caller only borrows a
601        // budgeted selection, so sharing is the whole point of the cache.
602        few_shot_cache().clear();
603        let workspace = tempfile::tempdir().expect("workspace");
604        let examples_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
605        std::fs::create_dir_all(&examples_dir).expect("mkdir");
606        std::fs::write(examples_dir.join("demo.md"), "---\ntags: [demo]\n---\nDemo body\n").expect("write");
607
608        let first = FewShotStore::load(Some(workspace.path()), None);
609        assert_eq!(first.len(), 1);
610        let second = FewShotStore::load(Some(workspace.path()), None);
611        let third = FewShotStore::load(Some(workspace.path()), None);
612        assert_eq!(second.len(), 1);
613        assert!(Arc::ptr_eq(&second, &third), "warm loads must share the cached store");
614    }
615
616    #[test]
617    #[serial_test::serial]
618    fn load_preserves_workspace_precedence_and_refreshes_edits() {
619        few_shot_cache().clear();
620        let workspace = tempfile::tempdir().expect("workspace");
621        let home = tempfile::tempdir().expect("home");
622        let workspace_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
623        let home_dir = home.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
624        std::fs::create_dir_all(&workspace_dir).expect("workspace examples");
625        std::fs::create_dir_all(&home_dir).expect("home examples");
626        std::fs::write(home_dir.join("review.md"), "---\ntags: [review]\n---\nhome\n").expect("home example");
627        std::fs::write(workspace_dir.join("review.md"), "---\ntags: [review]\n---\nworkspace\n")
628            .expect("workspace example");
629
630        let first = FewShotStore::load(Some(workspace.path()), Some(home.path()));
631        assert_eq!(first.examples[0].body, "workspace");
632
633        std::fs::write(workspace_dir.join("review.md"), "---\ntags: [review]\n---\nupdated\n").expect("edit example");
634        few_shot_cache().force_metadata_poll();
635        let second = FewShotStore::load(Some(workspace.path()), Some(home.path()));
636        assert_eq!(second.examples[0].body, "updated");
637    }
638
639    #[test]
640    #[serial_test::serial]
641    fn cache_detects_additions_deletions_and_missing_sources() {
642        few_shot_cache().clear();
643        let workspace = tempfile::tempdir().expect("workspace");
644        let examples_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
645
646        assert!(FewShotStore::load(Some(workspace.path()), None).is_empty());
647        std::fs::create_dir_all(&examples_dir).expect("examples directory");
648        std::fs::write(examples_dir.join("one.md"), "---\ntags: [one]\n---\none\n").expect("one example");
649        few_shot_cache().force_metadata_poll();
650        assert_eq!(FewShotStore::load(Some(workspace.path()), None).len(), 1);
651
652        std::fs::remove_file(examples_dir.join("one.md")).expect("remove example");
653        few_shot_cache().force_metadata_poll();
654        assert!(FewShotStore::load(Some(workspace.path()), None).is_empty());
655    }
656
657    #[test]
658    #[serial_test::serial]
659    fn expired_cache_entry_reparses_even_when_metadata_is_unchanged() {
660        few_shot_cache().clear();
661        let workspace = tempfile::tempdir().expect("workspace");
662        let examples_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
663        std::fs::create_dir_all(&examples_dir).expect("examples directory");
664        std::fs::write(examples_dir.join("ttl.md"), "---\ntags: [ttl]\n---\nold\n").expect("old example");
665
666        let first = FewShotStore::load(Some(workspace.path()), None);
667        assert_eq!(first.examples[0].body, "old");
668        std::fs::write(examples_dir.join("ttl.md"), "---\ntags: [ttl]\n---\nnew\n").expect("new example");
669        few_shot_cache().force_expiration();
670        let second = FewShotStore::load(Some(workspace.path()), None);
671        assert_eq!(second.examples[0].body, "new");
672    }
673
674    #[test]
675    #[serial_test::serial]
676    fn cache_hit_preserves_rendered_few_shot_prompt() {
677        few_shot_cache().clear();
678        let workspace = tempfile::tempdir().expect("workspace");
679        let examples_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
680        std::fs::create_dir_all(&examples_dir).expect("examples directory");
681        std::fs::write(
682            examples_dir.join("render.md"),
683            "---\ntags: [render]\nsummary: Stable output\n---\nRender this example.\n",
684        )
685        .expect("render example");
686
687        let cold = FewShotStore::load(Some(workspace.path()), None);
688        let cold_selected = cold.select("render", DEFAULT_FEW_SHOT_BUDGET_TOKENS);
689        let cold_rendered = render_few_shot_section(&cold_selected);
690
691        let warm = FewShotStore::load(Some(workspace.path()), None);
692        let warm_selected = warm.select("render", DEFAULT_FEW_SHOT_BUDGET_TOKENS);
693        let warm_rendered = render_few_shot_section(&warm_selected);
694        assert_eq!(cold_rendered, warm_rendered);
695    }
696
697    #[tokio::test(flavor = "current_thread")]
698    #[serial_test::serial]
699    async fn concurrent_cache_misses_share_the_parsed_store() {
700        few_shot_cache().clear();
701        let workspace = tempfile::tempdir().expect("workspace");
702        let examples_dir = workspace.path().join(PROMPTS_PARENT).join(EXAMPLES_DIR);
703        std::fs::create_dir_all(&examples_dir).expect("examples directory");
704        std::fs::write(examples_dir.join("concurrent.md"), "---\ntags: [concurrent]\n---\nbody\n").expect("example");
705
706        let (first, second) = tokio::join!(
707            FewShotStore::load_async(Some(workspace.path()), None),
708            FewShotStore::load_async(Some(workspace.path()), None),
709        );
710        assert_eq!(first.examples, second.examples);
711    }
712}