Skip to main content

agent_doc/
debounce.rs

1//! # Module: debounce
2//!
3//! ## Spec
4//! - Provides a shared typing-debounce mechanism used by all editor plugins (JetBrains, VS Code,
5//!   Neovim, Zed) so they share identical timing logic via the agent-doc FFI layer.
6//! - In-process state: a `Mutex<HashMap<PathBuf, Instant>>` (`LAST_CHANGE`) records the last
7//!   edit timestamp per file path.
8//! - Cross-process state: each `document_changed` call also writes a millisecond Unix timestamp
9//!   to `.agent-doc/typing/<hash>` so CLI invocations running in a separate process can detect
10//!   active typing. The hash is derived from the file path string via `DefaultHasher`.
11//!   Cross-process writes are best-effort and never block the caller.
12//! - `is_idle` / `await_idle` operate on in-process state (same process as the plugin).
13//! - `is_typing_via_file` / `await_idle_via_file` operate on the file-based indicator (CLI use).
14//! - Files with no recorded `document_changed` call are considered idle by `is_idle`; this
15//!   prevents `await_idle` from blocking forever on untracked documents.
16//! - `is_tracked` distinguishes "never seen" from "seen and idle" for non-blocking probes.
17//! - `await_idle` polls every 100 ms and returns `false` if `timeout_ms` expires before idle.
18//!
19//! ## Agentic Contracts
20//! - `document_changed(file: &str)` — records now as last-change time; writes typing indicator
21//!   file (best-effort); never panics.
22//! - `is_idle(file, debounce_ms) -> bool` — `true` if elapsed ≥ `debounce_ms` or file untracked.
23//! - `is_tracked(file) -> bool` — `true` if at least one `document_changed` was recorded.
24//! - `await_idle(file, debounce_ms, timeout_ms) -> bool` — blocks until idle or timeout; 100 ms
25//!   poll interval.
26//! - `is_typing_via_file(file, debounce_ms) -> bool` — reads indicator file; `false` if absent or
27//!   timestamp older than `debounce_ms`.
28//! - `await_idle_via_file(file, debounce_ms, timeout_ms) -> bool` — file-based blocking variant.
29//!
30//! ## Evals
31//! - idle_no_changes: file never passed to `document_changed` → `is_idle` returns `true`
32//! - not_idle_after_change: immediately after `document_changed` with 1500 ms window → `false`
33//! - idle_after_debounce: 50 ms sleep with 10 ms debounce → `is_idle` returns `true`
34//! - await_immediate: untracked file, `await_idle` → returns `true` in < 200 ms
35//! - await_settle: `document_changed` then `await_idle` with 200 ms debounce → waits ≥ 200 ms
36//! - typing_indicator_written: `document_changed` on file with `.agent-doc/typing/` dir →
37//!   `is_typing_via_file` returns `true` within 2000 ms window
38//! - typing_indicator_expires: 50 ms after change with 10 ms debounce →
39//!   `is_typing_via_file` returns `false`
40//! - no_indicator_file: nonexistent path → `is_typing_via_file` returns `false`
41
42use std::collections::HashMap;
43use std::path::PathBuf;
44use std::sync::Mutex;
45use std::time::Instant;
46
47/// Global state: last change timestamp per file.
48static LAST_CHANGE: Mutex<Option<HashMap<PathBuf, Instant>>> = Mutex::new(None);
49
50fn with_state<R>(f: impl FnOnce(&mut HashMap<PathBuf, Instant>) -> R) -> R {
51    let mut guard = LAST_CHANGE.lock().unwrap();
52    let map = guard.get_or_insert_with(HashMap::new);
53    f(map)
54}
55
56/// Record a document change event for the given file.
57///
58/// Called by editor plugins on every document modification.
59/// Also writes a typing indicator file for cross-process visibility.
60pub fn document_changed(file: &str) {
61    let path = PathBuf::from(file);
62    with_state(|map| {
63        map.insert(path.clone(), Instant::now());
64    });
65    // Write cross-process typing indicator (best-effort, never block)
66    if let Err(e) = write_typing_indicator(file) {
67        eprintln!("[debounce] typing indicator write failed for {:?}: {}", file, e);
68    }
69}
70
71/// Check if the document has been idle (no changes) for at least `debounce_ms`.
72///
73/// Returns `true` if no recent changes (safe to run), `false` if still active.
74/// For untracked files (no `document_changed` ever called), returns `true` —
75/// the blocking `await_idle` relies on this to not wait forever.
76pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
77    let path = PathBuf::from(file);
78    with_state(|map| {
79        match map.get(&path) {
80            None => true, // No recorded changes — idle
81            Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
82        }
83    })
84}
85
86/// Check if the document has been tracked (at least one `document_changed` call recorded).
87///
88/// Used by non-blocking probes to distinguish "never tracked" from "tracked and idle".
89/// If a file is untracked, the probe should be conservative (assume not idle).
90pub fn is_tracked(file: &str) -> bool {
91    let path = PathBuf::from(file);
92    with_state(|map| map.contains_key(&path))
93}
94
95/// Return the number of tracked files in the debounce state.
96pub fn tracked_count() -> usize {
97    with_state(|map| map.len())
98}
99
100/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
101///
102/// Returns `true` if idle was reached, `false` if timed out.
103///
104/// Poll interval: 100ms (responsive without busy-waiting).
105pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
106    let start = Instant::now();
107    let timeout = std::time::Duration::from_millis(timeout_ms);
108    let poll_interval = std::time::Duration::from_millis(100);
109
110    loop {
111        if is_idle(file, debounce_ms) {
112            return true;
113        }
114        if start.elapsed() >= timeout {
115            return false;
116        }
117        std::thread::sleep(poll_interval);
118    }
119}
120
121// ── Cross-process typing bridge ──
122
123/// Directory for typing indicator files, relative to project root.
124const TYPING_DIR: &str = ".agent-doc/typing";
125
126/// Write a typing indicator file for the given document path.
127/// The file contains a Unix timestamp (milliseconds) of the last edit.
128fn write_typing_indicator(file: &str) -> std::io::Result<()> {
129    let typing_path = typing_indicator_path(file);
130    if let Some(parent) = typing_path.parent() {
131        std::fs::create_dir_all(parent)?;
132    }
133    let now = std::time::SystemTime::now()
134        .duration_since(std::time::UNIX_EPOCH)
135        .unwrap_or_default()
136        .as_millis();
137    std::fs::write(&typing_path, now.to_string())
138}
139
140/// Compute the typing indicator file path for a document.
141fn typing_indicator_path(file: &str) -> PathBuf {
142    use std::hash::{Hash, Hasher};
143    let mut hasher = std::collections::hash_map::DefaultHasher::new();
144    file.hash(&mut hasher);
145    let hash = hasher.finish();
146    // Walk up to find .agent-doc/ directory (one pop per level, no skip)
147    let mut dir = PathBuf::from(file);
148    dir.pop(); // Start from file's parent
149    loop {
150        if dir.join(".agent-doc").is_dir() {
151            return dir.join(TYPING_DIR).join(format!("{:016x}", hash));
152        }
153        if !dir.pop() {
154            // Fallback: use file's parent directory
155            let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
156            return parent.join(TYPING_DIR).join(format!("{:016x}", hash));
157        }
158    }
159}
160
161/// Check if the document has a recent typing indicator (cross-process).
162///
163/// Returns `true` if the typing indicator exists and was updated within
164/// `debounce_ms` milliseconds. Used by CLI preflight to detect active typing
165/// from a plugin running in a different process.
166pub fn is_typing_via_file(file: &str, debounce_ms: u64) -> bool {
167    let path = typing_indicator_path(file);
168    match std::fs::read_to_string(&path) {
169        Ok(content) => {
170            if let Ok(ts_ms) = content.trim().parse::<u128>() {
171                let now = std::time::SystemTime::now()
172                    .duration_since(std::time::UNIX_EPOCH)
173                    .unwrap_or_default()
174                    .as_millis();
175                now.saturating_sub(ts_ms) < debounce_ms as u128
176            } else {
177                false
178            }
179        }
180        Err(_) => false, // No indicator file — not typing
181    }
182}
183
184/// Block until the typing indicator shows idle, or timeout.
185///
186/// Used by CLI preflight to wait for plugin-side typing to settle.
187/// Returns `true` if idle was reached, `false` if timed out.
188pub fn await_idle_via_file(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
189    let start = Instant::now();
190    let timeout = std::time::Duration::from_millis(timeout_ms);
191    let poll_interval = std::time::Duration::from_millis(100);
192
193    loop {
194        if !is_typing_via_file(file, debounce_ms) {
195            return true;
196        }
197        if start.elapsed() >= timeout {
198            return false;
199        }
200        std::thread::sleep(poll_interval);
201    }
202}
203
204// ── Response status signal (file-based) ──
205
206/// Status directory for cross-process signals.
207const STATUS_DIR: &str = ".agent-doc/status";
208
209/// Set the response status for a file.
210///
211/// Status values: "generating", "writing", "routing", "idle"
212/// Writes a file signal to `.agent-doc/status/` for cross-process visibility.
213pub fn set_status(file: &str, status: &str) {
214    let _ = write_status_file(file, status);
215}
216
217/// Get the response status for a file.
218///
219/// Returns "idle" if no status file exists or it's stale (>30s).
220pub fn get_status(file: &str) -> String {
221    get_status_via_file(file)
222}
223
224/// Check if any operation is in progress for a file.
225///
226/// Returns `true` if status is NOT "idle". Used by plugins to avoid
227/// triggering routes during active operations.
228pub fn is_busy(file: &str) -> bool {
229    get_status(file) != "idle"
230}
231
232/// Get status from file signal (cross-process).
233///
234/// Returns "idle" if no status file exists or it's stale (>30s).
235pub fn get_status_via_file(file: &str) -> String {
236    let path = status_file_path(file);
237    match std::fs::read_to_string(&path) {
238        Ok(content) => {
239            // Format: "status:timestamp_ms"
240            let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
241            if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
242                let now = std::time::SystemTime::now()
243                    .duration_since(std::time::UNIX_EPOCH)
244                    .unwrap_or_default()
245                    .as_millis();
246                // Stale after 30s — operation probably crashed
247                if now.saturating_sub(ts) < 30_000 {
248                    return parts[0].to_string();
249                }
250            }
251            "idle".to_string()
252        }
253        Err(_) => "idle".to_string(),
254    }
255}
256
257fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
258    let path = status_file_path(file);
259    if status == "idle" {
260        let _ = std::fs::remove_file(&path);
261        return Ok(());
262    }
263    if let Some(parent) = path.parent() {
264        std::fs::create_dir_all(parent)?;
265    }
266    let now = std::time::SystemTime::now()
267        .duration_since(std::time::UNIX_EPOCH)
268        .unwrap_or_default()
269        .as_millis();
270    std::fs::write(&path, format!("{}:{}", status, now))
271}
272
273fn status_file_path(file: &str) -> PathBuf {
274    use std::hash::{Hash, Hasher};
275    let mut hasher = std::collections::hash_map::DefaultHasher::new();
276    file.hash(&mut hasher);
277    let hash = hasher.finish();
278    let mut dir = PathBuf::from(file);
279    dir.pop(); // Start from file's parent
280    loop {
281        if dir.join(".agent-doc").is_dir() {
282            return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
283        }
284        if !dir.pop() {
285            let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
286            return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn idle_when_no_changes() {
297        assert!(is_idle("/tmp/test-no-changes.md", 1500));
298    }
299
300    #[test]
301    fn not_idle_after_change() {
302        document_changed("/tmp/test-just-changed.md");
303        assert!(!is_idle("/tmp/test-just-changed.md", 1500));
304    }
305
306    #[test]
307    fn idle_after_debounce_period() {
308        document_changed("/tmp/test-debounce.md");
309        // Use a very short debounce for testing
310        std::thread::sleep(std::time::Duration::from_millis(50));
311        assert!(is_idle("/tmp/test-debounce.md", 10));
312    }
313
314    #[test]
315    fn await_idle_returns_immediately_when_idle() {
316        let start = Instant::now();
317        assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
318        assert!(start.elapsed().as_millis() < 200);
319    }
320
321    #[test]
322    fn await_idle_waits_for_settle() {
323        document_changed("/tmp/test-await-settle.md");
324        let start = Instant::now();
325        assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
326        assert!(start.elapsed().as_millis() >= 200);
327    }
328
329    #[test]
330    fn typing_indicator_written_on_change() {
331        let tmp = tempfile::TempDir::new().unwrap();
332        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
333        std::fs::create_dir_all(&agent_doc_dir).unwrap();
334        let doc = tmp.path().join("test-typing.md");
335        std::fs::write(&doc, "test").unwrap();
336        let doc_str = doc.to_string_lossy().to_string();
337
338        document_changed(&doc_str);
339
340        // Should detect typing within 2000ms window
341        assert!(is_typing_via_file(&doc_str, 2000));
342    }
343
344    #[test]
345    fn typing_indicator_expires() {
346        let tmp = tempfile::TempDir::new().unwrap();
347        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
348        std::fs::create_dir_all(&agent_doc_dir).unwrap();
349        let doc = tmp.path().join("test-typing-expire.md");
350        std::fs::write(&doc, "test").unwrap();
351        let doc_str = doc.to_string_lossy().to_string();
352
353        document_changed(&doc_str);
354        std::thread::sleep(std::time::Duration::from_millis(50));
355
356        // With a 10ms debounce, 50ms ago should NOT be typing
357        assert!(!is_typing_via_file(&doc_str, 10));
358    }
359
360    #[test]
361    fn no_typing_indicator_means_not_typing() {
362        assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
363    }
364
365    // ── GAP 1: Mtime Granularity ──
366    // Route path relies on filesystem mtime which may have coarse resolution (100ms-1s).
367    // Can miss rapid successive edits if they occur within mtime granularity window.
368
369    #[test]
370    fn rapid_edits_within_mtime_granularity() {
371        let tmp = tempfile::TempDir::new().unwrap();
372        let doc = tmp.path().join("test-rapid-edits.md");
373        std::fs::write(&doc, "initial").unwrap();
374        let doc_str = doc.to_string_lossy().to_string();
375
376        // Simulate rapid edits: write → is_idle check → write again
377        // All within filesystem mtime granularity (e.g., 1s on some systems)
378        document_changed(&doc_str);
379        // This may not detect the second change on coarse-grained filesystems
380        document_changed(&doc_str);
381
382        // Should be not idle, but mtime-based detection may fail
383        assert!(!is_idle(&doc_str, 500));
384    }
385
386    // ── GAP 2: Untracked File Edge Case ──
387    // Untracked files return idle=true immediately, preventing await_idle from blocking forever.
388    // But is_tracked() should distinguish "never-tracked" from "tracked and idle".
389
390    #[test]
391    fn is_tracked_distinguishes_untracked_from_idle() {
392        let file_never_tracked = "/tmp/never-tracked.md";
393        let file_tracked_idle = "/tmp/tracked-idle.md";
394
395        // Never-tracked file
396        assert!(!is_tracked(file_never_tracked));
397        assert!(is_idle(file_never_tracked, 1500)); // idle=true for untracked
398
399        // Tracked file that is now idle
400        document_changed(file_tracked_idle);
401        std::thread::sleep(std::time::Duration::from_millis(50));
402        assert!(is_tracked(file_tracked_idle)); // is_tracked=true
403        assert!(is_idle(file_tracked_idle, 10)); // also idle=true after debounce
404    }
405
406    #[test]
407    fn await_idle_on_untracked_file_returns_immediately() {
408        let start = Instant::now();
409        // Untracked file should return immediately, not wait
410        assert!(await_idle("/tmp/untracked-await.md", 1500, 5000));
411        assert!(start.elapsed().as_millis() < 500);
412    }
413
414    #[test]
415    fn await_idle_respects_tracked_state() {
416        let tracked_file = "/tmp/tracked-await.md";
417        document_changed(tracked_file);
418        assert!(is_tracked(tracked_file));
419
420        // await_idle should wait for debounce even though tracked
421        let start = Instant::now();
422        assert!(await_idle(tracked_file, 200, 5000));
423        assert!(start.elapsed().as_millis() >= 200);
424    }
425
426    // ── GAP 3: Hash Collision Risk ──
427    // DefaultHasher is non-cryptographic; collision risk is low but possible.
428    // Need to verify collision handling in typing indicator files.
429
430    #[test]
431    fn hash_collision_handling() {
432        let tmp = tempfile::TempDir::new().unwrap();
433        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
434        std::fs::create_dir_all(&agent_doc_dir).unwrap();
435
436        let doc1 = tmp.path().join("doc1.md");
437        let doc2 = tmp.path().join("doc2.md");
438        std::fs::write(&doc1, "test").unwrap();
439        std::fs::write(&doc2, "test").unwrap();
440
441        let doc1_str = doc1.to_string_lossy().to_string();
442        let doc2_str = doc2.to_string_lossy().to_string();
443
444        document_changed(&doc1_str);
445        let path1 = typing_indicator_path(&doc1_str);
446
447        document_changed(&doc2_str);
448        let path2 = typing_indicator_path(&doc2_str);
449
450        // If hashes collide, paths are identical
451        // This is a low-probability event but should be documented
452        if path1 == path2 {
453            // Collision detected: last write wins, earlier timestamp is overwritten
454            // is_typing_via_file for both returns true for the more recent change only
455            assert!(is_typing_via_file(&doc2_str, 2000)); // Most recent
456        } else {
457            // No collision: separate files, both typing
458            assert!(is_typing_via_file(&doc1_str, 2000));
459            assert!(is_typing_via_file(&doc2_str, 2000));
460        }
461    }
462
463    // ── GAP 4: Reactive Mode CRDT Assumption ──
464    // Watch daemon reactive path (zero debounce) assumes CRDT merge always converges.
465    // If CRDT merge fails or produces unexpected state, reactive mode could cause issues.
466    // Note: This is tested at watch.rs level; debounce.rs cannot test CRDT semantics.
467
468    #[test]
469    fn reactive_mode_requires_zero_debounce() {
470        // Reactive mode relies on zero debounce (instant idle check).
471        // With debounce_ms=0, elapsed >= 0 is always true.
472        let reactive_file = "/tmp/reactive.md";
473        document_changed(reactive_file);
474
475        // With zero debounce, even freshly changed files return idle=true
476        // because elapsed (even nanoseconds) >= 0
477        assert!(is_idle(reactive_file, 0));
478
479        // This means reactive mode responds instantly but assumes CRDT merge
480        // will handle concurrent edits correctly (see Gap 4 in SPEC.md)
481    }
482
483    // ── GAP 5: Status File Staleness (30s timeout) ──
484    // Response status files expire after 30s with assumption operation crashed.
485    // No recovery for long-running operations or delayed writes.
486
487    #[test]
488    fn status_file_staleness_timeout() {
489        let tmp = tempfile::TempDir::new().unwrap();
490        let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
491        std::fs::create_dir_all(&agent_doc_dir).unwrap();
492        let doc = tmp.path().join("test-status.md");
493        std::fs::write(&doc, "test").unwrap();
494        let doc_str = doc.to_string_lossy().to_string();
495
496        set_status(&doc_str, "generating");
497        assert_eq!(get_status(&doc_str), "generating");
498
499        // get_status now delegates to get_status_via_file
500        assert_eq!(get_status_via_file(&doc_str), "generating");
501
502        // After 30s, get_status_via_file returns "idle" (assumes operation crashed)
503        // This test documents the 30s assumption but cannot test actual passage of time
504        // in unit tests without mocking SystemTime.
505    }
506
507    #[test]
508    fn status_file_cleared_on_idle() {
509        let tmp = tempfile::TempDir::new().unwrap();
510        let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
511        std::fs::create_dir_all(&agent_doc_dir).unwrap();
512        let doc = tmp.path().join("test-status-clear.md");
513        std::fs::write(&doc, "test").unwrap();
514        let doc_str = doc.to_string_lossy().to_string();
515
516        set_status(&doc_str, "writing");
517        assert!(is_busy(&doc_str));
518
519        set_status(&doc_str, "idle");
520        assert!(!is_busy(&doc_str));
521        assert_eq!(get_status(&doc_str), "idle");
522    }
523
524    // ── GAP 6: Hardcoded Timing Constants ──
525    // Preflight hardcodes 1500ms for typing indicator debounce (vs 500ms poll debounce).
526    // Not configurable; one-size-fits-all fails for slow CI or fast typists.
527
528    #[test]
529    fn timing_constants_are_configurable() {
530        let tmp = tempfile::TempDir::new().unwrap();
531        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
532        std::fs::create_dir_all(&agent_doc_dir).unwrap();
533        let doc = tmp.path().join("test-timing.md");
534        std::fs::write(&doc, "test").unwrap();
535        let doc_str = doc.to_string_lossy().to_string();
536
537        document_changed(&doc_str);
538
539        // is_typing_via_file accepts debounce_ms as parameter — good
540        assert!(is_typing_via_file(&doc_str, 2000));
541        assert!(is_typing_via_file(&doc_str, 100));
542
543        // await_idle_via_file also accepts debounce_ms — configurable
544        let start = Instant::now();
545        let result = await_idle_via_file(&doc_str, 10, 1000);
546        let elapsed = start.elapsed();
547
548        // With 10ms debounce, should wait ~10ms then return true
549        assert!(result);
550        assert!(elapsed.as_millis() >= 10);
551
552        // preflight.rs hardcodes 1500ms in is_typing_via_file call
553        // This is a documentation test: ideally 1500ms should be configurable
554    }
555
556    #[test]
557    fn await_idle_via_file_respects_poll_interval() {
558        let tmp = tempfile::TempDir::new().unwrap();
559        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
560        std::fs::create_dir_all(&agent_doc_dir).unwrap();
561        let doc = tmp.path().join("test-poll-interval.md");
562        std::fs::write(&doc, "test").unwrap();
563        let doc_str = doc.to_string_lossy().to_string();
564
565        document_changed(&doc_str);
566
567        let start = Instant::now();
568        // With 100ms debounce, poll should check ~every 100ms
569        assert!(await_idle_via_file(&doc_str, 100, 5000));
570        let elapsed = start.elapsed().as_millis();
571
572        // Should wait at least the debounce time (allowing some jitter)
573        assert!(elapsed >= 100);
574    }
575
576    // ── GAP 7: Directory-walk bug (depth-1) ──
577    // typing_indicator_path and status_file_path had a double-pop bug:
578    // each loop iteration popped twice, skipping every other directory level.
579    // Files at depth 1 from the project root (e.g. tasks/file.md) failed to
580    // find .agent-doc/ and fell back to the wrong path.
581
582    #[test]
583    fn typing_indicator_found_for_file_one_level_deep() {
584        let tmp = tempfile::TempDir::new().unwrap();
585        // .agent-doc at project root
586        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
587        std::fs::create_dir_all(&agent_doc_dir).unwrap();
588        // File one level deep (tasks/file.md pattern)
589        let subdir = tmp.path().join("tasks");
590        std::fs::create_dir_all(&subdir).unwrap();
591        let doc = subdir.join("test-depth1.md");
592        std::fs::write(&doc, "test").unwrap();
593        let doc_str = doc.to_string_lossy().to_string();
594
595        document_changed(&doc_str);
596
597        // Should find .agent-doc/ at project root, not fall back to wrong path
598        assert!(is_typing_via_file(&doc_str, 2000));
599    }
600
601    #[test]
602    fn typing_indicator_found_for_file_two_levels_deep() {
603        let tmp = tempfile::TempDir::new().unwrap();
604        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
605        std::fs::create_dir_all(&agent_doc_dir).unwrap();
606        // File two levels deep (tasks/software/file.md pattern)
607        let subdir = tmp.path().join("tasks").join("software");
608        std::fs::create_dir_all(&subdir).unwrap();
609        let doc = subdir.join("test-depth2.md");
610        std::fs::write(&doc, "test").unwrap();
611        let doc_str = doc.to_string_lossy().to_string();
612
613        document_changed(&doc_str);
614
615        assert!(is_typing_via_file(&doc_str, 2000));
616    }
617
618    #[test]
619    fn status_found_for_file_one_level_deep() {
620        let tmp = tempfile::TempDir::new().unwrap();
621        let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
622        std::fs::create_dir_all(&agent_doc_dir).unwrap();
623        let subdir = tmp.path().join("tasks");
624        std::fs::create_dir_all(&subdir).unwrap();
625        let doc = subdir.join("test-status-depth1.md");
626        std::fs::write(&doc, "test").unwrap();
627        let doc_str = doc.to_string_lossy().to_string();
628
629        set_status(&doc_str, "generating");
630
631        // get_status delegates to file-based check — must find .agent-doc at project root
632        assert_eq!(get_status_via_file(&doc_str), "generating");
633    }
634}