Skip to main content

vtcode_core/core/agent/
harness_artifacts.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6const TASKS_DIR: &str = ".vtcode/tasks";
7const CURRENT_TASK_FILE: &str = "current_task.md";
8const CURRENT_SPEC_FILE: &str = "current_spec.md";
9const CURRENT_CONTRACT_FILE: &str = "current_contract.md";
10const CURRENT_EVALUATION_FILE: &str = "current_evaluation.md";
11const CURRENT_SPRINT_CONTRACT_FILE: &str = "current_sprint_contract.md";
12const CURRENT_OUTCOME_VERIFICATION_FILE: &str = "current_outcome_verification.md";
13const CURRENT_FEATURE_LIST_FILE: &str = "current_feature_list.md";
14const SUMMARY_PREVIEW_CHARS: usize = 280;
15
16/// Return the path to the current task tracker file.
17pub fn current_task_path(workspace_root: &Path) -> PathBuf {
18    workspace_root.join(TASKS_DIR).join(CURRENT_TASK_FILE)
19}
20
21/// Return the path to the current context reset manifest file.
22pub fn current_context_reset_path(workspace_root: &Path) -> PathBuf {
23    workspace_root
24        .join(TASKS_DIR)
25        .join(crate::core::agent::context_reset::CONTEXT_RESET_FILE)
26}
27
28/// Return the path to the current spec artifact file.
29pub fn current_spec_path(workspace_root: &Path) -> PathBuf {
30    workspace_root.join(TASKS_DIR).join(CURRENT_SPEC_FILE)
31}
32
33/// Return the path to the current contract artifact file.
34pub fn current_contract_path(workspace_root: &Path) -> PathBuf {
35    workspace_root.join(TASKS_DIR).join(CURRENT_CONTRACT_FILE)
36}
37
38/// Return the path to the current evaluation artifact file.
39pub fn current_evaluation_path(workspace_root: &Path) -> PathBuf {
40    workspace_root.join(TASKS_DIR).join(CURRENT_EVALUATION_FILE)
41}
42
43/// Return the path to the current sprint contract artifact file.
44///
45/// The sprint contract is the pre-sprint negotiation artifact: the generator
46/// and evaluator agree on scope, acceptance criteria, and out-of-scope items
47/// before implementation begins. This follows the long-running harness pattern
48/// where "vague user stories become testable contracts."
49pub fn current_sprint_contract_path(workspace_root: &Path) -> PathBuf {
50    workspace_root.join(TASKS_DIR).join(CURRENT_SPRINT_CONTRACT_FILE)
51}
52
53/// Return the path to the current outcome verification artifact file.
54///
55/// The outcome verification records what commands were run to verify, what the
56/// actual output was, and whether tests/build passed. This enforces "evaluate
57/// outcomes, not claims" -- the agent cannot declare success without showing
58/// actual verification output.
59pub fn current_outcome_verification_path(workspace_root: &Path) -> PathBuf {
60    workspace_root.join(TASKS_DIR).join(CURRENT_OUTCOME_VERIFICATION_FILE)
61}
62
63/// Return the paths of all harness artifacts that currently exist on disk.
64pub fn existing_harness_artifact_paths(workspace_root: &Path) -> Vec<PathBuf> {
65    [
66        current_spec_path(workspace_root),
67        current_contract_path(workspace_root),
68        current_evaluation_path(workspace_root),
69        current_sprint_contract_path(workspace_root),
70        current_outcome_verification_path(workspace_root),
71        current_feature_list_path(workspace_root),
72    ]
73    .into_iter()
74    .filter(|path| path.exists())
75    .collect()
76}
77
78/// Read a short summary of the current spec artifact, or `None` if unavailable.
79pub fn read_spec_summary(workspace_root: &Path) -> Option<String> {
80    read_markdown_summary(&current_spec_path(workspace_root), "Spec")
81}
82
83/// Like [`read_spec_summary`], but drop the file when it predates `not_before`.
84pub fn read_spec_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
85    read_markdown_summary_fresh(&current_spec_path(workspace_root), "Spec", not_before)
86}
87
88/// Read a short summary of the current contract artifact, or `None` if unavailable.
89pub fn read_contract_summary(workspace_root: &Path) -> Option<String> {
90    read_markdown_summary(&current_contract_path(workspace_root), "Contract")
91}
92
93/// Read a short summary of the current evaluation artifact, or `None` if unavailable.
94pub fn read_evaluation_summary(workspace_root: &Path) -> Option<String> {
95    read_markdown_summary(&current_evaluation_path(workspace_root), "Evaluation")
96}
97
98/// Like [`read_evaluation_summary`], but drop the file when it predates `not_before`.
99pub fn read_evaluation_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
100    read_markdown_summary_fresh(&current_evaluation_path(workspace_root), "Evaluation", not_before)
101}
102
103/// Like [`read_contract_summary`], but drop the file when it predates `not_before`.
104pub fn read_contract_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
105    read_markdown_summary_fresh(&current_contract_path(workspace_root), "Contract", not_before)
106}
107
108/// Like [`read_feature_list_summary`], but drop the file when it predates `not_before`.
109pub fn read_feature_list_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
110    read_markdown_summary_fresh(&current_feature_list_path(workspace_root), "FeatureList", not_before)
111}
112
113/// Like [`read_sprint_contract_summary`], but drop the file when it predates `not_before`.
114pub fn read_sprint_contract_summary_fresh(workspace_root: &Path, not_before: Option<SystemTime>) -> Option<String> {
115    read_markdown_summary_fresh(&current_sprint_contract_path(workspace_root), "SprintContract", not_before)
116}
117
118/// Like [`read_outcome_verification_summary`], but drop the file when it predates `not_before`.
119pub fn read_outcome_verification_summary_fresh(
120    workspace_root: &Path,
121    not_before: Option<SystemTime>,
122) -> Option<String> {
123    read_markdown_summary_fresh(&current_outcome_verification_path(workspace_root), "OutcomeVerification", not_before)
124}
125
126/// Best-effort start time of `session_id`, used to reject leftover workspace
127/// task artifacts that predate the session. Returns `None` when the session
128/// directory is missing so callers can fall back to unfiltered reads.
129pub fn session_artifact_cutoff(workspace_root: &Path, session_id: &str) -> Option<SystemTime> {
130    // Canonical store path (full-length sanitize_id), plus the raw id as a
131    // fallback for callers that never opened the store.
132    let candidates = [
133        vtcode_memory::session_directory(workspace_root, session_id),
134        workspace_root.join(".vtcode").join("sessions").join(session_id),
135    ];
136    for dir in candidates {
137        let Ok(metadata) = fs::metadata(&dir) else {
138            continue;
139        };
140        if let Some(cutoff) = artifact_cutoff_from_metadata(&metadata) {
141            return Some(cutoff);
142        }
143    }
144    None
145}
146
147/// Cutoff derived from a session directory's timestamps.
148///
149/// True creation time is preferred. When the filesystem has no birth time
150/// (btime-less Linux filesystems), `modified()` is the best available lower
151/// bound: it sits slightly after the real session start, so stale leftovers
152/// from earlier sessions still compare stale while artifacts written during
153/// the session stay live. Returning `None` here would disable the leftover
154/// filter entirely (fail-open) on those platforms.
155fn artifact_cutoff_from_metadata(metadata: &fs::Metadata) -> Option<SystemTime> {
156    metadata.created().ok().or_else(|| metadata.modified().ok())
157}
158
159/// Archive a fully-checked `current_task.md` so a finished checklist cannot
160/// describe the next session. Incomplete checklists stay in place.
161///
162/// Returns the archive path when a move happened. The archive name carries a
163/// timestamp and uuid (same contract as blocked-handoff archives) because the
164/// live file is gone after the move: a deterministic session-id-only name
165/// would let a resumed session's second completion destroy the only copy.
166pub fn archive_completed_current_task(workspace_root: &Path, session_id: &str) -> Result<Option<PathBuf>> {
167    let task_path = current_task_path(workspace_root);
168    let Ok(content) = fs::read_to_string(&task_path) else {
169        return Ok(None);
170    };
171    let checklist: Vec<&str> = content
172        .lines()
173        .map(str::trim_start)
174        .filter(|line| line.starts_with("- ["))
175        .collect();
176    let is_checked = |line: &str| line.starts_with("- [x]") || line.starts_with("- [X]");
177    if checklist.is_empty() || !checklist.iter().all(|line| is_checked(line)) {
178        return Ok(None);
179    }
180    let archive_dir = workspace_root.join(TASKS_DIR).join("archive");
181    fs::create_dir_all(&archive_dir).with_context(|| format!("create task archive dir {}", archive_dir.display()))?;
182    let archive_name = format!(
183        "current_task-{}-{}-{}.md",
184        filename_safe_id(session_id, 64),
185        chrono::Utc::now().format("%Y%m%dT%H%M%SZ"),
186        uuid::Uuid::new_v4()
187    );
188    let archive_path = archive_dir.join(archive_name);
189    fs::rename(&task_path, &archive_path)
190        .with_context(|| format!("archive completed task tracker to {}", archive_path.display()))?;
191    Ok(Some(archive_path))
192}
193
194/// Filename-safe session id prefix for archive side-cars (not envelope names —
195/// those use `sanitize_session_id`'s fixed 32-char contract).
196fn filename_safe_id(id: &str, max_chars: usize) -> String {
197    id.chars()
198        .map(|c| {
199            if c.is_alphanumeric() || c == '_' || c == '-' {
200                c
201            } else {
202                '_'
203            }
204        })
205        .take(max_chars)
206        .collect()
207}
208
209/// Write the spec artifact content to disk and return the path.
210pub async fn write_spec(workspace_root: &Path, content: &str) -> Result<PathBuf> {
211    let path = current_spec_path(workspace_root);
212    write_artifact(path.as_path(), content, "current spec").await?;
213    Ok(path)
214}
215
216/// Write the evaluation artifact content to disk and return the path.
217pub async fn write_evaluation(workspace_root: &Path, content: &str) -> Result<PathBuf> {
218    let path = current_evaluation_path(workspace_root);
219    write_artifact(path.as_path(), content, "current evaluation").await?;
220    Ok(path)
221}
222
223/// Write the contract artifact content to disk and return the path.
224pub async fn write_contract(workspace_root: &Path, content: &str) -> Result<PathBuf> {
225    let path = current_contract_path(workspace_root);
226    write_artifact(path.as_path(), content, "current contract").await?;
227    Ok(path)
228}
229
230/// Read a short summary of the sprint contract artifact, or `None` if unavailable.
231pub fn read_sprint_contract_summary(workspace_root: &Path) -> Option<String> {
232    read_markdown_summary(&current_sprint_contract_path(workspace_root), "SprintContract")
233}
234
235/// Write the sprint contract artifact content to disk and return the path.
236///
237/// The sprint contract is the pre-sprint negotiation artifact where generator
238/// and evaluator agree on scope and acceptance criteria before code is written.
239pub async fn write_sprint_contract(workspace_root: &Path, content: &str) -> Result<PathBuf> {
240    let path = current_sprint_contract_path(workspace_root);
241    write_artifact(path.as_path(), content, "sprint contract").await?;
242    Ok(path)
243}
244
245/// Read a short summary of the outcome verification artifact, or `None` if unavailable.
246pub fn read_outcome_verification_summary(workspace_root: &Path) -> Option<String> {
247    read_markdown_summary(&current_outcome_verification_path(workspace_root), "OutcomeVerification")
248}
249
250/// Return the path to the current feature list artifact file.
251///
252/// The feature list is a persistent artifact the planner creates and the
253/// evaluator modifies during feedback-driven replanning. It lists the
254/// project's features with their acceptance criteria, so each agent session
255/// can pick up an incremental unit of work. Following the long-running
256/// harness pattern: "the planner can achieve replanning by modifying external
257/// files: feature_list, sprint_contract, known_issues, next_actions."
258pub fn current_feature_list_path(workspace_root: &Path) -> PathBuf {
259    workspace_root.join(TASKS_DIR).join(CURRENT_FEATURE_LIST_FILE)
260}
261
262/// Read a short summary of the feature list artifact, or `None` if unavailable.
263pub fn read_feature_list_summary(workspace_root: &Path) -> Option<String> {
264    read_markdown_summary(&current_feature_list_path(workspace_root), "FeatureList")
265}
266
267/// Write the feature list artifact content to disk and return the path.
268pub async fn write_feature_list(workspace_root: &Path, content: &str) -> Result<PathBuf> {
269    let path = current_feature_list_path(workspace_root);
270    write_artifact(path.as_path(), content, "feature list").await?;
271    Ok(path)
272}
273
274/// Write the outcome verification artifact content to disk and return the path.
275///
276/// This records actual verification commands and their output, enforcing
277/// "evaluate outcomes, not claims" -- the agent must show proof of verification.
278pub async fn write_outcome_verification(workspace_root: &Path, content: &str) -> Result<PathBuf> {
279    let path = current_outcome_verification_path(workspace_root);
280    write_artifact(path.as_path(), content, "outcome verification").await?;
281    Ok(path)
282}
283
284async fn write_artifact(path: &Path, content: &str, label: &str) -> Result<()> {
285    if let Some(parent) = path.parent() {
286        tokio::fs::create_dir_all(parent)
287            .await
288            .with_context(|| format!("create {} directory {}", label, parent.display()))?;
289    }
290
291    tokio::fs::write(path, content)
292        .await
293        .with_context(|| format!("write {} {}", label, path.display()))?;
294    Ok(())
295}
296
297fn read_markdown_summary(path: &Path, label: &str) -> Option<String> {
298    let content = fs::read_to_string(path).ok()?;
299    let lines = content
300        .lines()
301        .map(str::trim)
302        .filter(|line| !line.is_empty())
303        .filter(|line| !line.starts_with('#'))
304        .take(4)
305        .collect::<Vec<_>>();
306    if lines.is_empty() {
307        return None;
308    }
309
310    let joined = lines.join(" | ");
311    Some(format!("{label}: {}", truncate_summary(&joined)))
312}
313
314/// Grace applied when comparing artifact mtime to session start.
315///
316/// A spec written as a handoff before `vtcode` starts is still live for this
317/// session; only leftovers from *earlier* sessions (days/weeks old) must be
318/// dropped. 24h covers normal handoff workflows; the Jul-24 fixture class is
319/// far outside it.
320const ARTIFACT_FRESHNESS_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
321
322/// Whether `path` predates the freshness window of `not_before`.
323///
324/// Single source of the artifact staleness rule shared by the fresh readers
325/// and the memory-envelope constraints channel. Unreadable metadata counts as
326/// stale so a checked artifact never falls back to content of unknown age;
327/// `not_before == None` disables the check.
328pub fn artifact_is_stale(path: &Path, not_before: Option<SystemTime>) -> bool {
329    let Some(not_before) = not_before else {
330        return false;
331    };
332    let Some(modified) = fs::metadata(path).ok().and_then(|meta| meta.modified().ok()) else {
333        return true;
334    };
335    let stale_before = not_before
336        .checked_sub(ARTIFACT_FRESHNESS_GRACE)
337        .unwrap_or(SystemTime::UNIX_EPOCH);
338    modified < stale_before
339}
340
341/// Read a markdown summary only when the file is at least as new as `not_before`.
342///
343/// Workspace-global task artifacts outlive their session. A leftover fixture
344/// must not describe a later session's memory envelope or orient snapshot.
345/// Files within [`ARTIFACT_FRESHNESS_GRACE`] of `not_before` still count as
346/// live so a just-written handoff artifact is kept.
347fn read_markdown_summary_fresh(path: &Path, label: &str, not_before: Option<SystemTime>) -> Option<String> {
348    if artifact_is_stale(path, not_before) {
349        return None;
350    }
351    read_markdown_summary(path, label)
352}
353
354fn truncate_summary(text: &str) -> String {
355    vtcode_commons::formatting::truncate_within(text, SUMMARY_PREVIEW_CHARS, "...")
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use tempfile::tempdir;
362
363    #[tokio::test]
364    async fn writes_and_summarizes_spec_and_evaluation_artifacts() {
365        let temp = tempdir().expect("tempdir");
366
367        write_spec(temp.path(), "# Spec\n\nBuild a stronger exec harness.\n\nKeep it resumable.\n")
368            .await
369            .expect("write spec");
370        write_contract(temp.path(), "# Contract\n\n- Deliver the requested change.\n- Verify with cargo check.\n")
371            .await
372            .expect("write contract");
373        write_evaluation(temp.path(), "# Evaluation\n\nVerdict: fail\n\nNeed another revision round.\n")
374            .await
375            .expect("write evaluation");
376
377        let paths = existing_harness_artifact_paths(temp.path());
378        assert_eq!(paths.len(), 3);
379        assert_eq!(
380            read_spec_summary(temp.path()),
381            Some("Spec: Build a stronger exec harness. | Keep it resumable.".to_string())
382        );
383        assert_eq!(
384            read_contract_summary(temp.path()),
385            Some("Contract: - Deliver the requested change. | - Verify with cargo check.".to_string())
386        );
387        assert_eq!(
388            read_evaluation_summary(temp.path()),
389            Some("Evaluation: Verdict: fail | Need another revision round.".to_string())
390        );
391    }
392
393    #[tokio::test]
394    async fn writes_and_summarizes_sprint_contract() {
395        let temp = tempdir().expect("tempdir");
396
397        write_sprint_contract(
398            temp.path(),
399            "# Sprint Contract\n\nScope: implement login endpoint.\nAcceptance: POST /login returns JWT.\n",
400        )
401        .await
402        .expect("write sprint contract");
403
404        let paths = existing_harness_artifact_paths(temp.path());
405        assert_eq!(paths.len(), 1);
406        assert_eq!(
407            read_sprint_contract_summary(temp.path()),
408            Some("SprintContract: Scope: implement login endpoint. | Acceptance: POST /login returns JWT.".to_string())
409        );
410    }
411
412    #[tokio::test]
413    async fn writes_and_summarizes_outcome_verification() {
414        let temp = tempdir().expect("tempdir");
415
416        write_outcome_verification(
417            temp.path(),
418            "# Outcome Verification\n\nCommand: cargo nextest run\nResult: 12 passed, 0 failed\nBuild: cargo check PASSED\n",
419        )
420        .await
421        .expect("write outcome verification");
422
423        let paths = existing_harness_artifact_paths(temp.path());
424        assert_eq!(paths.len(), 1);
425        assert_eq!(
426            read_outcome_verification_summary(temp.path()),
427            Some(
428                "OutcomeVerification: Command: cargo nextest run | Result: 12 passed, 0 failed | Build: cargo check PASSED"
429                    .to_string()
430            )
431        );
432    }
433
434    #[tokio::test]
435    async fn writes_and_summarizes_feature_list() {
436        let temp = tempdir().expect("tempdir");
437
438        write_feature_list(
439            temp.path(),
440            "# Feature List\n\n- [ ] Auth: login endpoint returns JWT\n- [x] API: health check endpoint\n",
441        )
442        .await
443        .expect("write feature list");
444
445        let paths = existing_harness_artifact_paths(temp.path());
446        assert_eq!(paths.len(), 1);
447        assert_eq!(
448            read_feature_list_summary(temp.path()),
449            Some("FeatureList: - [ ] Auth: login endpoint returns JWT | - [x] API: health check endpoint".to_string())
450        );
451    }
452
453    #[tokio::test]
454    async fn all_artifacts_counted_in_existing_paths() {
455        let temp = tempdir().expect("tempdir");
456
457        write_spec(temp.path(), "# Spec\ncontent\n").await.unwrap();
458        write_contract(temp.path(), "# Contract\ncontent\n").await.unwrap();
459        write_evaluation(temp.path(), "# Evaluation\ncontent\n").await.unwrap();
460        write_sprint_contract(temp.path(), "# Sprint\ncontent\n").await.unwrap();
461        write_outcome_verification(temp.path(), "# Outcome\ncontent\n").await.unwrap();
462        write_feature_list(temp.path(), "# Features\ncontent\n").await.unwrap();
463
464        let paths = existing_harness_artifact_paths(temp.path());
465        assert_eq!(paths.len(), 6);
466    }
467
468    #[test]
469    fn stale_spec_summary_is_dropped_for_later_sessions() {
470        let temp = tempdir().expect("tempdir");
471        let spec_path = current_spec_path(temp.path());
472        fs::create_dir_all(spec_path.parent().expect("parent")).expect("tasks dir");
473        fs::write(&spec_path, "# Execution Spec\nExplore the codebase and summarize.\n").expect("write spec");
474        // Make the fixture look like a leftover from a prior session (days old).
475        let old = SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
476        let file = fs::File::options().write(true).open(&spec_path).expect("open");
477        file.set_modified(old).expect("set mtime");
478
479        assert!(read_spec_summary(temp.path()).is_some(), "unfiltered read still sees the file");
480        assert!(
481            read_spec_summary_fresh(temp.path(), Some(SystemTime::now())).is_none(),
482            "a leftover fixture must not describe a later session"
483        );
484        assert!(
485            read_spec_summary_fresh(temp.path(), Some(old + std::time::Duration::from_secs(10))).is_some(),
486            "fresh reads still accept artifacts written during the session"
487        );
488    }
489
490    #[test]
491    fn handoff_artifact_written_just_before_session_start_stays_live() {
492        let temp = tempdir().expect("tempdir");
493        let spec_path = current_spec_path(temp.path());
494        fs::create_dir_all(spec_path.parent().expect("parent")).expect("tasks dir");
495        fs::write(&spec_path, "# Spec\n\nShip the residual hygiene fix.\n").expect("write spec");
496
497        // Session directory is created *after* the handoff spec is written.
498        let session_start = SystemTime::now();
499        assert!(
500            read_spec_summary_fresh(temp.path(), Some(session_start)).is_some(),
501            "a just-written handoff artifact must survive the freshness cutoff"
502        );
503    }
504
505    #[test]
506    fn archive_completed_current_task_moves_only_fully_checked_trackers() {
507        let temp = tempdir().expect("tempdir");
508        let task_path = current_task_path(temp.path());
509        fs::create_dir_all(task_path.parent().expect("parent")).expect("tasks dir");
510
511        fs::write(&task_path, "# Work\n\n- [ ] open item\n- [x] done item\n").expect("write partial");
512        assert!(
513            archive_completed_current_task(temp.path(), "session-a")
514                .expect("archive")
515                .is_none(),
516            "incomplete checklists stay live"
517        );
518        assert!(task_path.exists());
519
520        fs::write(&task_path, "# Work\n\n- [x] done one\n- [x] done two\n").expect("write complete");
521        let archived = archive_completed_current_task(temp.path(), "session-a")
522            .expect("archive")
523            .expect("fully-checked tracker is archived");
524        assert!(!task_path.exists(), "live path must be clear for the next plan");
525        assert!(
526            archived
527                .file_name()
528                .and_then(|n| n.to_str())
529                .is_some_and(|name| { name.starts_with("current_task-session-a-") && name.ends_with(".md") })
530        );
531        assert!(archived.exists());
532    }
533
534    #[test]
535    fn archive_completed_current_task_never_overwrites_a_prior_archive() {
536        // Resume-in-place reuses the session id, so a second completion must
537        // not rename over the first archive: the live file is gone after the
538        // move and the archive is the only copy.
539        let temp = tempdir().expect("tempdir");
540        let task_path = current_task_path(temp.path());
541        fs::create_dir_all(task_path.parent().expect("parent")).expect("tasks dir");
542
543        fs::write(&task_path, "# Work\n\n- [x] first completion\n").expect("write complete");
544        let first = archive_completed_current_task(temp.path(), "session-a")
545            .expect("archive")
546            .expect("first archive");
547        fs::write(&task_path, "# Work\n\n- [x] second completion\n").expect("write complete");
548        let second = archive_completed_current_task(temp.path(), "session-a")
549            .expect("archive")
550            .expect("second archive");
551
552        assert_ne!(first, second, "each completion gets its own archive file");
553        assert!(first.exists(), "first archive must survive the second completion");
554        assert!(second.exists());
555        assert_eq!(fs::read_to_string(&first).expect("first content"), "# Work\n\n- [x] first completion\n");
556    }
557
558    #[test]
559    fn session_artifact_cutoff_is_some_for_existing_dir_and_none_for_missing() {
560        let temp = tempdir().expect("tempdir");
561        assert!(
562            session_artifact_cutoff(temp.path(), "sess-missing").is_none(),
563            "missing session dir keeps the unfiltered-read fallback"
564        );
565
566        let session_dir = temp.path().join(".vtcode").join("sessions").join("sess-live");
567        fs::create_dir_all(&session_dir).expect("session dir");
568        let cutoff = session_artifact_cutoff(temp.path(), "sess-live").expect("cutoff");
569        assert!(
570            cutoff <= SystemTime::now(),
571            "an existing session dir must yield a real cutoff so the leftover filter stays enabled"
572        );
573    }
574}