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 (A: file, B: FFI) ──
205
206/// Status directory for cross-process signals (Option A).
207const STATUS_DIR: &str = ".agent-doc/status";
208
209/// In-process status (Option B: FFI).
210static STATUS: Mutex<Option<HashMap<PathBuf, String>>> = Mutex::new(None);
211
212fn with_status<R>(f: impl FnOnce(&mut HashMap<PathBuf, String>) -> R) -> R {
213    let mut guard = STATUS.lock().unwrap();
214    let map = guard.get_or_insert_with(HashMap::new);
215    f(map)
216}
217
218/// Set the response status for a file.
219///
220/// Status values: "generating", "writing", "routing", "idle"
221/// Sets both in-process state (B) and file signal (A).
222pub fn set_status(file: &str, status: &str) {
223    let path = PathBuf::from(file);
224    with_status(|map| {
225        if status == "idle" {
226            map.remove(&path);
227        } else {
228            map.insert(path, status.to_string());
229        }
230    });
231    let _ = write_status_file(file, status);
232}
233
234/// Get the response status for a file (in-process, Option B).
235///
236/// Returns "idle" if no status is set.
237pub fn get_status(file: &str) -> String {
238    let path = PathBuf::from(file);
239    with_status(|map| {
240        map.get(&path).cloned().unwrap_or_else(|| "idle".to_string())
241    })
242}
243
244/// Check if any operation is in progress for a file (in-process, Option B).
245///
246/// Returns `true` if status is NOT "idle". Used by plugins to avoid
247/// triggering routes during active operations.
248pub fn is_busy(file: &str) -> bool {
249    get_status(file) != "idle"
250}
251
252/// Get status from file signal (cross-process, Option A).
253///
254/// Returns "idle" if no status file exists or it's stale (>30s).
255pub fn get_status_via_file(file: &str) -> String {
256    let path = status_file_path(file);
257    match std::fs::read_to_string(&path) {
258        Ok(content) => {
259            // Format: "status:timestamp_ms"
260            let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
261            if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
262                let now = std::time::SystemTime::now()
263                    .duration_since(std::time::UNIX_EPOCH)
264                    .unwrap_or_default()
265                    .as_millis();
266                // Stale after 30s — operation probably crashed
267                if now.saturating_sub(ts) < 30_000 {
268                    return parts[0].to_string();
269                }
270            }
271            "idle".to_string()
272        }
273        Err(_) => "idle".to_string(),
274    }
275}
276
277fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
278    let path = status_file_path(file);
279    if status == "idle" {
280        let _ = std::fs::remove_file(&path);
281        return Ok(());
282    }
283    if let Some(parent) = path.parent() {
284        std::fs::create_dir_all(parent)?;
285    }
286    let now = std::time::SystemTime::now()
287        .duration_since(std::time::UNIX_EPOCH)
288        .unwrap_or_default()
289        .as_millis();
290    std::fs::write(&path, format!("{}:{}", status, now))
291}
292
293fn status_file_path(file: &str) -> PathBuf {
294    use std::hash::{Hash, Hasher};
295    let mut hasher = std::collections::hash_map::DefaultHasher::new();
296    file.hash(&mut hasher);
297    let hash = hasher.finish();
298    let mut dir = PathBuf::from(file);
299    dir.pop(); // Start from file's parent
300    loop {
301        if dir.join(".agent-doc").is_dir() {
302            return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
303        }
304        if !dir.pop() {
305            let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
306            return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn idle_when_no_changes() {
317        assert!(is_idle("/tmp/test-no-changes.md", 1500));
318    }
319
320    #[test]
321    fn not_idle_after_change() {
322        document_changed("/tmp/test-just-changed.md");
323        assert!(!is_idle("/tmp/test-just-changed.md", 1500));
324    }
325
326    #[test]
327    fn idle_after_debounce_period() {
328        document_changed("/tmp/test-debounce.md");
329        // Use a very short debounce for testing
330        std::thread::sleep(std::time::Duration::from_millis(50));
331        assert!(is_idle("/tmp/test-debounce.md", 10));
332    }
333
334    #[test]
335    fn await_idle_returns_immediately_when_idle() {
336        let start = Instant::now();
337        assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
338        assert!(start.elapsed().as_millis() < 200);
339    }
340
341    #[test]
342    fn await_idle_waits_for_settle() {
343        document_changed("/tmp/test-await-settle.md");
344        let start = Instant::now();
345        assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
346        assert!(start.elapsed().as_millis() >= 200);
347    }
348
349    #[test]
350    fn typing_indicator_written_on_change() {
351        let tmp = tempfile::TempDir::new().unwrap();
352        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
353        std::fs::create_dir_all(&agent_doc_dir).unwrap();
354        let doc = tmp.path().join("test-typing.md");
355        std::fs::write(&doc, "test").unwrap();
356        let doc_str = doc.to_string_lossy().to_string();
357
358        document_changed(&doc_str);
359
360        // Should detect typing within 2000ms window
361        assert!(is_typing_via_file(&doc_str, 2000));
362    }
363
364    #[test]
365    fn typing_indicator_expires() {
366        let tmp = tempfile::TempDir::new().unwrap();
367        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
368        std::fs::create_dir_all(&agent_doc_dir).unwrap();
369        let doc = tmp.path().join("test-typing-expire.md");
370        std::fs::write(&doc, "test").unwrap();
371        let doc_str = doc.to_string_lossy().to_string();
372
373        document_changed(&doc_str);
374        std::thread::sleep(std::time::Duration::from_millis(50));
375
376        // With a 10ms debounce, 50ms ago should NOT be typing
377        assert!(!is_typing_via_file(&doc_str, 10));
378    }
379
380    #[test]
381    fn no_typing_indicator_means_not_typing() {
382        assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
383    }
384
385    // ── GAP 1: Mtime Granularity ──
386    // Route path relies on filesystem mtime which may have coarse resolution (100ms-1s).
387    // Can miss rapid successive edits if they occur within mtime granularity window.
388
389    #[test]
390    fn rapid_edits_within_mtime_granularity() {
391        let tmp = tempfile::TempDir::new().unwrap();
392        let doc = tmp.path().join("test-rapid-edits.md");
393        std::fs::write(&doc, "initial").unwrap();
394        let doc_str = doc.to_string_lossy().to_string();
395
396        // Simulate rapid edits: write → is_idle check → write again
397        // All within filesystem mtime granularity (e.g., 1s on some systems)
398        document_changed(&doc_str);
399        // This may not detect the second change on coarse-grained filesystems
400        document_changed(&doc_str);
401
402        // Should be not idle, but mtime-based detection may fail
403        assert!(!is_idle(&doc_str, 500));
404    }
405
406    // ── GAP 2: Untracked File Edge Case ──
407    // Untracked files return idle=true immediately, preventing await_idle from blocking forever.
408    // But is_tracked() should distinguish "never-tracked" from "tracked and idle".
409
410    #[test]
411    fn is_tracked_distinguishes_untracked_from_idle() {
412        let file_never_tracked = "/tmp/never-tracked.md";
413        let file_tracked_idle = "/tmp/tracked-idle.md";
414
415        // Never-tracked file
416        assert!(!is_tracked(file_never_tracked));
417        assert!(is_idle(file_never_tracked, 1500)); // idle=true for untracked
418
419        // Tracked file that is now idle
420        document_changed(file_tracked_idle);
421        std::thread::sleep(std::time::Duration::from_millis(50));
422        assert!(is_tracked(file_tracked_idle)); // is_tracked=true
423        assert!(is_idle(file_tracked_idle, 10)); // also idle=true after debounce
424    }
425
426    #[test]
427    fn await_idle_on_untracked_file_returns_immediately() {
428        let start = Instant::now();
429        // Untracked file should return immediately, not wait
430        assert!(await_idle("/tmp/untracked-await.md", 1500, 5000));
431        assert!(start.elapsed().as_millis() < 500);
432    }
433
434    #[test]
435    fn await_idle_respects_tracked_state() {
436        let tracked_file = "/tmp/tracked-await.md";
437        document_changed(tracked_file);
438        assert!(is_tracked(tracked_file));
439
440        // await_idle should wait for debounce even though tracked
441        let start = Instant::now();
442        assert!(await_idle(tracked_file, 200, 5000));
443        assert!(start.elapsed().as_millis() >= 200);
444    }
445
446    // ── GAP 3: Hash Collision Risk ──
447    // DefaultHasher is non-cryptographic; collision risk is low but possible.
448    // Need to verify collision handling in typing indicator files.
449
450    #[test]
451    fn hash_collision_handling() {
452        let tmp = tempfile::TempDir::new().unwrap();
453        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
454        std::fs::create_dir_all(&agent_doc_dir).unwrap();
455
456        let doc1 = tmp.path().join("doc1.md");
457        let doc2 = tmp.path().join("doc2.md");
458        std::fs::write(&doc1, "test").unwrap();
459        std::fs::write(&doc2, "test").unwrap();
460
461        let doc1_str = doc1.to_string_lossy().to_string();
462        let doc2_str = doc2.to_string_lossy().to_string();
463
464        document_changed(&doc1_str);
465        let path1 = typing_indicator_path(&doc1_str);
466
467        document_changed(&doc2_str);
468        let path2 = typing_indicator_path(&doc2_str);
469
470        // If hashes collide, paths are identical
471        // This is a low-probability event but should be documented
472        if path1 == path2 {
473            // Collision detected: last write wins, earlier timestamp is overwritten
474            // is_typing_via_file for both returns true for the more recent change only
475            assert!(is_typing_via_file(&doc2_str, 2000)); // Most recent
476        } else {
477            // No collision: separate files, both typing
478            assert!(is_typing_via_file(&doc1_str, 2000));
479            assert!(is_typing_via_file(&doc2_str, 2000));
480        }
481    }
482
483    // ── GAP 4: Reactive Mode CRDT Assumption ──
484    // Watch daemon reactive path (zero debounce) assumes CRDT merge always converges.
485    // If CRDT merge fails or produces unexpected state, reactive mode could cause issues.
486    // Note: This is tested at watch.rs level; debounce.rs cannot test CRDT semantics.
487
488    #[test]
489    fn reactive_mode_requires_zero_debounce() {
490        // Reactive mode relies on zero debounce (instant idle check).
491        // With debounce_ms=0, elapsed >= 0 is always true.
492        let reactive_file = "/tmp/reactive.md";
493        document_changed(reactive_file);
494
495        // With zero debounce, even freshly changed files return idle=true
496        // because elapsed (even nanoseconds) >= 0
497        assert!(is_idle(reactive_file, 0));
498
499        // This means reactive mode responds instantly but assumes CRDT merge
500        // will handle concurrent edits correctly (see Gap 4 in SPEC.md)
501    }
502
503    // ── GAP 5: Status File Staleness (30s timeout) ──
504    // Response status files expire after 30s with assumption operation crashed.
505    // No recovery for long-running operations or delayed writes.
506
507    #[test]
508    fn status_file_staleness_timeout() {
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.md");
513        std::fs::write(&doc, "test").unwrap();
514        let doc_str = doc.to_string_lossy().to_string();
515
516        set_status(&doc_str, "generating");
517        assert_eq!(get_status(&doc_str), "generating");
518
519        // Status should remain until explicitly cleared
520        assert_eq!(get_status_via_file(&doc_str), "generating");
521
522        // After 30s, get_status_via_file returns "idle" (assumes operation crashed)
523        // This test documents the 30s assumption but cannot test actual passage of time
524        // in unit tests without mocking SystemTime.
525    }
526
527    #[test]
528    fn status_file_cleared_on_idle() {
529        let tmp = tempfile::TempDir::new().unwrap();
530        let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
531        std::fs::create_dir_all(&agent_doc_dir).unwrap();
532        let doc = tmp.path().join("test-status-clear.md");
533        std::fs::write(&doc, "test").unwrap();
534        let doc_str = doc.to_string_lossy().to_string();
535
536        set_status(&doc_str, "writing");
537        assert!(is_busy(&doc_str));
538
539        set_status(&doc_str, "idle");
540        assert!(!is_busy(&doc_str));
541        assert_eq!(get_status(&doc_str), "idle");
542    }
543
544    // ── GAP 6: Hardcoded Timing Constants ──
545    // Preflight hardcodes 1500ms for typing indicator debounce (vs 500ms poll debounce).
546    // Not configurable; one-size-fits-all fails for slow CI or fast typists.
547
548    #[test]
549    fn timing_constants_are_configurable() {
550        let tmp = tempfile::TempDir::new().unwrap();
551        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
552        std::fs::create_dir_all(&agent_doc_dir).unwrap();
553        let doc = tmp.path().join("test-timing.md");
554        std::fs::write(&doc, "test").unwrap();
555        let doc_str = doc.to_string_lossy().to_string();
556
557        document_changed(&doc_str);
558
559        // is_typing_via_file accepts debounce_ms as parameter — good
560        assert!(is_typing_via_file(&doc_str, 2000));
561        assert!(is_typing_via_file(&doc_str, 100));
562
563        // await_idle_via_file also accepts debounce_ms — configurable
564        let start = Instant::now();
565        let result = await_idle_via_file(&doc_str, 10, 1000);
566        let elapsed = start.elapsed();
567
568        // With 10ms debounce, should wait ~10ms then return true
569        assert!(result);
570        assert!(elapsed.as_millis() >= 10);
571
572        // preflight.rs hardcodes 1500ms in is_typing_via_file call
573        // This is a documentation test: ideally 1500ms should be configurable
574    }
575
576    #[test]
577    fn await_idle_via_file_respects_poll_interval() {
578        let tmp = tempfile::TempDir::new().unwrap();
579        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
580        std::fs::create_dir_all(&agent_doc_dir).unwrap();
581        let doc = tmp.path().join("test-poll-interval.md");
582        std::fs::write(&doc, "test").unwrap();
583        let doc_str = doc.to_string_lossy().to_string();
584
585        document_changed(&doc_str);
586
587        let start = Instant::now();
588        // With 100ms debounce, poll should check ~every 100ms
589        assert!(await_idle_via_file(&doc_str, 100, 5000));
590        let elapsed = start.elapsed().as_millis();
591
592        // Should wait at least the debounce time (allowing some jitter)
593        assert!(elapsed >= 100);
594    }
595
596    // ── GAP 7: Directory-walk bug (depth-1) ──
597    // typing_indicator_path and status_file_path had a double-pop bug:
598    // each loop iteration popped twice, skipping every other directory level.
599    // Files at depth 1 from the project root (e.g. tasks/file.md) failed to
600    // find .agent-doc/ and fell back to the wrong path.
601
602    #[test]
603    fn typing_indicator_found_for_file_one_level_deep() {
604        let tmp = tempfile::TempDir::new().unwrap();
605        // .agent-doc at project root
606        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
607        std::fs::create_dir_all(&agent_doc_dir).unwrap();
608        // File one level deep (tasks/file.md pattern)
609        let subdir = tmp.path().join("tasks");
610        std::fs::create_dir_all(&subdir).unwrap();
611        let doc = subdir.join("test-depth1.md");
612        std::fs::write(&doc, "test").unwrap();
613        let doc_str = doc.to_string_lossy().to_string();
614
615        document_changed(&doc_str);
616
617        // Should find .agent-doc/ at project root, not fall back to wrong path
618        assert!(is_typing_via_file(&doc_str, 2000));
619    }
620
621    #[test]
622    fn typing_indicator_found_for_file_two_levels_deep() {
623        let tmp = tempfile::TempDir::new().unwrap();
624        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
625        std::fs::create_dir_all(&agent_doc_dir).unwrap();
626        // File two levels deep (tasks/software/file.md pattern)
627        let subdir = tmp.path().join("tasks").join("software");
628        std::fs::create_dir_all(&subdir).unwrap();
629        let doc = subdir.join("test-depth2.md");
630        std::fs::write(&doc, "test").unwrap();
631        let doc_str = doc.to_string_lossy().to_string();
632
633        document_changed(&doc_str);
634
635        assert!(is_typing_via_file(&doc_str, 2000));
636    }
637
638    #[test]
639    fn status_found_for_file_one_level_deep() {
640        let tmp = tempfile::TempDir::new().unwrap();
641        let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
642        std::fs::create_dir_all(&agent_doc_dir).unwrap();
643        let subdir = tmp.path().join("tasks");
644        std::fs::create_dir_all(&subdir).unwrap();
645        let doc = subdir.join("test-status-depth1.md");
646        std::fs::write(&doc, "test").unwrap();
647        let doc_str = doc.to_string_lossy().to_string();
648
649        set_status(&doc_str, "generating");
650
651        // get_status uses in-process map (always works), but cross-process file check
652        // must find .agent-doc at project root
653        assert_eq!(get_status_via_file(&doc_str), "generating");
654    }
655}