Skip to main content

everruns_core/capabilities/
progress_guard.rs

1// Progress guard for coding-agent efficiency.
2//
3// This is intentionally runtime-enforced rather than prompt-only: it observes
4// tool traffic and injects a warning into the next tool result when the turn is
5// spending many tools on investigation without edits or validation.
6
7use crate::atoms::{PostToolExecHook, PostToolExecHookPriority};
8use crate::capabilities::{Capability, CapabilityStatus};
9use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
10use crate::traits::ToolContext;
11use async_trait::async_trait;
12use serde_json::{Map, Value, json};
13use sha2::{Digest, Sha256};
14use std::collections::{HashMap, HashSet, VecDeque, hash_map::DefaultHasher};
15use std::hash::{Hash, Hasher};
16use std::sync::{Arc, Mutex};
17
18pub const PROGRESS_GUARD_CAPABILITY_ID: &str = "progress_guard";
19
20const EXPLORATION_WITHOUT_PROGRESS_THRESHOLD: usize = 24;
21const CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD: usize = 48;
22const REPEATED_EXPLORATION_THRESHOLD: usize = 5;
23const ZERO_EVIDENCE_SEARCH_THRESHOLD: usize = 3;
24const TRUNCATED_EXPLORATION_THRESHOLD: usize = 2;
25const REPEATED_STATUS_THRESHOLD: usize = 3;
26const WAITING_WINDOW_SIZE: usize = 8;
27const WAITING_WINDOW_THRESHOLD: usize = 4;
28const SEMANTIC_HISTORY_LIMIT: usize = 512;
29const TRACKED_PATH_LIMIT: usize = 1024;
30const MIN_REUSABLE_RESULT_BYTES: usize = 512;
31
32pub struct ProgressGuardCapability {
33    state: Arc<Mutex<ProgressGuardState>>,
34}
35
36impl ProgressGuardCapability {
37    pub fn new() -> Self {
38        Self {
39            state: Arc::new(Mutex::new(ProgressGuardState::default())),
40        }
41    }
42}
43
44impl Default for ProgressGuardCapability {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50#[async_trait]
51impl Capability for ProgressGuardCapability {
52    fn id(&self) -> &str {
53        PROGRESS_GUARD_CAPABILITY_ID
54    }
55
56    fn name(&self) -> &str {
57        "Progress Guard"
58    }
59
60    fn description(&self) -> &str {
61        "Warns the coding agent when tool usage suggests investigation without progress."
62    }
63
64    fn status(&self) -> CapabilityStatus {
65        CapabilityStatus::Available
66    }
67
68    fn category(&self) -> Option<&str> {
69        Some("Guardrails")
70    }
71
72    fn is_guardrail(&self) -> bool {
73        true
74    }
75
76    // No system-prompt contribution. Every warning this capability emits already
77    // names the situation and the required next action, and it arrives in the
78    // tool result at the moment it applies. Pre-announcing the mechanism on every
79    // turn paid for a warning that usually never fires.
80
81    fn system_prompt_preview(&self) -> Option<String> {
82        Some(
83            "<capability id=\"progress_guard\">\nWarns on investigation without progress.\n</capability>"
84                .to_string(),
85        )
86    }
87
88    fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn PostToolExecHook>> {
89        vec![Arc::new(ProgressGuardHook {
90            state: self.state.clone(),
91        })]
92    }
93}
94
95#[derive(Default)]
96struct ProgressGuardState {
97    sessions: HashMap<String, SessionProgress>,
98}
99
100#[derive(Default)]
101struct SessionProgress {
102    tool_count: usize,
103    exploration_since_progress: usize,
104    mutation_count: usize,
105    validation_count: usize,
106    repeated_status_count: usize,
107    last_status_command: Option<String>,
108    recent_waiting: VecDeque<bool>,
109    repeated_exploration_count: usize,
110    last_exploration_signature: Option<String>,
111    consecutive_zero_evidence_searches: usize,
112    consecutive_truncated_exploration: usize,
113    warning_count: usize,
114    workspace_epoch: u64,
115    workspace_hashes: HashMap<String, String>,
116    recent_workspace_states: VecDeque<u64>,
117    seen_workspace_states: HashSet<u64>,
118    recent_validations: VecDeque<(u64, String)>,
119    seen_validations: HashSet<(u64, String)>,
120    recent_observations: VecDeque<String>,
121    observation_hashes: HashMap<String, [u8; 32]>,
122}
123
124impl SessionProgress {
125    fn observe(&mut self, tool_call: &ToolCall, result: &mut ToolResult) -> Option<String> {
126        self.tool_count += 1;
127        let class = classify_tool_call(tool_call);
128
129        match class {
130            ToolClass::Mutation => self.observe_mutation(tool_call, result),
131            ToolClass::Validation(command) => {
132                self.validation_count += 1;
133                self.reset_activity_streaks();
134                let validation = (self.validation_state_signature(), command);
135                if self.seen_validations.contains(&validation) {
136                    self.warning_count += 1;
137                    return Some(
138                        "progress_guard: repeated the same validation command on an unchanged workspace state. The result adds no new code evidence; use the existing result, change the relevant state, or explain why an external retry is necessary before running it again."
139                            .to_string(),
140                    );
141                }
142                remember_bounded(
143                    &mut self.seen_validations,
144                    &mut self.recent_validations,
145                    validation,
146                );
147                None
148            }
149            ToolClass::Waiting => {
150                self.exploration_since_progress += 1;
151                self.reset_result_streaks();
152                if self.observe_waiting_signal(true) {
153                    self.warning_count += 1;
154                    return Some(
155                        "progress_guard: repeated checks of an external event (CI run, PR checks/reviews) without other progress. Do not poll across turns: run one blocking watch detached via spawn_background (e.g. `gh pr checks --watch` or an `until <check>; do sleep 30; done` loop) and end the turn — completion wakes the agent. In one-shot mode, block on the spawned task with wait_task instead."
156                            .to_string(),
157                    );
158                }
159                self.exploration_warning()
160            }
161            ToolClass::Status(command) => {
162                self.exploration_since_progress += 1;
163                self.reset_result_streaks();
164                self.observe_waiting_signal(false);
165                if self.last_status_command.as_deref() == Some(command.as_str()) {
166                    self.repeated_status_count += 1;
167                } else {
168                    self.repeated_status_count = 1;
169                    self.last_status_command = Some(command);
170                }
171                if self.repeated_status_count >= REPEATED_STATUS_THRESHOLD {
172                    self.warning_count += 1;
173                    self.repeated_status_count = 0;
174                    return Some(
175                        "progress_guard: repeated git status/diff checks without an intervening edit or validation. Use the latest result, make a targeted change, run a decisive check, or explain why no change is needed."
176                            .to_string(),
177                    );
178                }
179                self.exploration_warning()
180            }
181            ToolClass::Exploration => {
182                self.exploration_since_progress += 1;
183                self.observe_waiting_signal(false);
184                if let Some(warning) = self.result_warning(tool_call, result) {
185                    return Some(warning);
186                }
187                if let Some(warning) = self.reuse_unchanged_observation(tool_call, result) {
188                    return Some(warning);
189                }
190                if let Some(warning) = self.repetition_warning(tool_call) {
191                    return Some(warning);
192                }
193                self.exploration_warning()
194            }
195            ToolClass::Other => {
196                self.observe_waiting_signal(false);
197                self.reset_result_streaks();
198                None
199            }
200        }
201    }
202
203    fn observe_mutation(&mut self, tool_call: &ToolCall, result: &ToolResult) -> Option<String> {
204        if result.error.is_some() || !mutation_was_applied(tool_call, result) {
205            return None;
206        }
207
208        self.mutation_count += 1;
209        self.reset_activity_streaks();
210        // A mutation changes the meaning of later repository evidence even when
211        // a file happens to return to the same bytes. Clear reuse state instead
212        // of serving a compact marker across an edit boundary.
213        self.recent_observations.clear();
214        self.observation_hashes.clear();
215
216        let Some(transition) = mutation_hash_transition(tool_call, result) else {
217            // Shell, delete, and structural edits can touch an unknown set of
218            // files. They make validation fresh, but retaining structured file
219            // hashes lets us still recognize a manifest cycle around lockfile
220            // updates and other adjacent mutations.
221            self.advance_opaque_mutation();
222            return None;
223        };
224
225        if self.workspace_hashes.len() >= TRACKED_PATH_LIMIT
226            && !self.workspace_hashes.contains_key(&transition.path)
227        {
228            self.reset_tracked_history();
229        }
230
231        if let Some(previous_hash) = transition.previous_hash {
232            if let Some(known_hash) = self.workspace_hashes.get(&transition.path)
233                && known_hash != &previous_hash
234            {
235                // An unobserved writer changed the file. Preserve safety by
236                // abandoning comparisons with the now-incomplete trajectory.
237                self.reset_tracked_history();
238            }
239            if !self.workspace_hashes.contains_key(&transition.path) {
240                self.workspace_hashes
241                    .insert(transition.path.clone(), previous_hash);
242                let previous_state = self.tracked_state_signature();
243                self.remember_workspace_state(previous_state);
244            }
245        }
246
247        self.workspace_hashes
248            .insert(transition.path, transition.content_hash);
249        let current_state = self.tracked_state_signature();
250        if self.remember_workspace_state(current_state) {
251            self.warning_count += 1;
252            return Some(
253                "progress_guard: this mutation returned to a recently seen workspace state (the same tracked content hashes). Confirm the revert is intentional; if this is an edit/validate cycle, keep the coherent state, report the blocker, and stop repeating the cycle."
254                    .to_string(),
255            );
256        }
257        None
258    }
259
260    fn reset_activity_streaks(&mut self) {
261        self.exploration_since_progress = 0;
262        self.repeated_status_count = 0;
263        self.last_status_command = None;
264        self.recent_waiting.clear();
265        self.repeated_exploration_count = 0;
266        self.last_exploration_signature = None;
267        self.reset_result_streaks();
268    }
269
270    fn observe_waiting_signal(&mut self, waiting: bool) -> bool {
271        // A bounded semantic window catches heterogeneous check/read/task-probe
272        // cycles while mutations and validations still clear it. Transparently
273        // moving an already-started shell process would risk replaying side
274        // effects and weakening one-shot cancellation, so the guard steers the
275        // next wait onto the existing durable background-task path instead.
276        self.recent_waiting.push_back(waiting);
277        if self.recent_waiting.len() > WAITING_WINDOW_SIZE {
278            self.recent_waiting.pop_front();
279        }
280        if self
281            .recent_waiting
282            .iter()
283            .filter(|waiting| **waiting)
284            .count()
285            >= WAITING_WINDOW_THRESHOLD
286        {
287            self.recent_waiting.clear();
288            return true;
289        }
290        false
291    }
292
293    fn advance_opaque_mutation(&mut self) {
294        self.workspace_epoch = self.workspace_epoch.wrapping_add(1);
295        self.recent_validations.clear();
296        self.seen_validations.clear();
297    }
298
299    fn reset_tracked_history(&mut self) {
300        self.workspace_hashes.clear();
301        self.recent_workspace_states.clear();
302        self.seen_workspace_states.clear();
303        self.advance_opaque_mutation();
304    }
305
306    fn tracked_state_signature(&self) -> u64 {
307        let mut entries = self.workspace_hashes.iter().collect::<Vec<_>>();
308        entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
309        let mut hasher = DefaultHasher::new();
310        entries.hash(&mut hasher);
311        hasher.finish()
312    }
313
314    fn validation_state_signature(&self) -> u64 {
315        let mut hasher = DefaultHasher::new();
316        self.workspace_epoch.hash(&mut hasher);
317        self.tracked_state_signature().hash(&mut hasher);
318        hasher.finish()
319    }
320
321    fn remember_workspace_state(&mut self, state: u64) -> bool {
322        if self.seen_workspace_states.contains(&state) {
323            return true;
324        }
325        remember_bounded(
326            &mut self.seen_workspace_states,
327            &mut self.recent_workspace_states,
328            state,
329        );
330        false
331    }
332
333    fn result_warning(&mut self, tool_call: &ToolCall, result: &ToolResult) -> Option<String> {
334        let Some(evidence) = exploration_evidence(tool_call, result) else {
335            self.reset_result_streaks();
336            return None;
337        };
338
339        if evidence.zero_matches {
340            self.consecutive_zero_evidence_searches += 1;
341        } else {
342            self.consecutive_zero_evidence_searches = 0;
343        }
344        if evidence.truncated {
345            self.consecutive_truncated_exploration += 1;
346        } else {
347            self.consecutive_truncated_exploration = 0;
348        }
349
350        if self.consecutive_zero_evidence_searches >= ZERO_EVIDENCE_SEARCH_THRESHOLD {
351            self.warning_count += 1;
352            self.consecutive_zero_evidence_searches = 0;
353            return Some(
354                "progress_guard: three consecutive searches returned zero matches. Stop varying broad terms: verify the path/scope and search contract, then use one targeted alternative or state that no evidence was found."
355                    .to_string(),
356            );
357        }
358        if self.consecutive_truncated_exploration >= TRUNCATED_EXPLORATION_THRESHOLD {
359            self.warning_count += 1;
360            self.consecutive_truncated_exploration = 0;
361            return Some(
362                "progress_guard: repeated exploration results were truncated. Narrow the query or path before requesting more output, then inspect the owning module and a small number of call sites."
363                    .to_string(),
364            );
365        }
366        None
367    }
368
369    fn reset_result_streaks(&mut self) {
370        self.consecutive_zero_evidence_searches = 0;
371        self.consecutive_truncated_exploration = 0;
372    }
373
374    fn reuse_unchanged_observation(
375        &mut self,
376        tool_call: &ToolCall,
377        result: &mut ToolResult,
378    ) -> Option<String> {
379        if result.error.is_some() {
380            return None;
381        }
382        let signature = exploration_signature(tool_call)?;
383        let value = result.result.as_ref()?;
384        let encoded = serde_json::to_vec(value).ok()?;
385        let result_hash: [u8; 32] = Sha256::digest(&encoded).into();
386
387        let unchanged = self.observation_hashes.get(&signature) == Some(&result_hash);
388        self.remember_observation(signature.clone(), result_hash);
389        if !unchanged || encoded.len() < MIN_REUSABLE_RESULT_BYTES {
390            return None;
391        }
392
393        // This won against generic oversized-result summarization in the focused
394        // discovery study: the first full result stays available, while an exact
395        // repeat becomes a small freshness proof instead of another lossy summary.
396        result.result = Some(json!({
397            "unchanged_since_last_read": true,
398            "tool": tool_call.name,
399        }));
400        self.warning_count += 1;
401        Some(
402            "progress_guard: this read/search result is unchanged since the same target was last inspected. Reuse the earlier full result; continue only if a different question or scope needs evidence."
403                .to_string(),
404        )
405    }
406
407    fn remember_observation(&mut self, signature: String, result_hash: [u8; 32]) {
408        if self.observation_hashes.contains_key(&signature) {
409            self.recent_observations
410                .retain(|candidate| candidate != &signature);
411        }
412        self.observation_hashes
413            .insert(signature.clone(), result_hash);
414        self.recent_observations.push_back(signature);
415        if self.recent_observations.len() > SEMANTIC_HISTORY_LIMIT
416            && let Some(expired) = self.recent_observations.pop_front()
417        {
418            self.observation_hashes.remove(&expired);
419        }
420    }
421
422    fn exploration_warning(&mut self) -> Option<String> {
423        if self.exploration_since_progress == EXPLORATION_WITHOUT_PROGRESS_THRESHOLD {
424            self.warning_count += 1;
425            return Some(format!(
426                "progress_guard: {EXPLORATION_WITHOUT_PROGRESS_THRESHOLD} investigation tools have run without an edit or validation. Narrow the hypothesis now: identify the exact missing evidence, make the smallest relevant change, or run one decisive verification command."
427            ));
428        }
429        if self.exploration_since_progress >= CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
430            self.warning_count += 1;
431            return Some(format!(
432                "progress_guard: checkpoint required after {count} investigation tools without an edit or validation. Stop reading broadly and produce a checkpoint before more exploration: facts learned, current hypothesis, and the next decisive action (edit, validation, or no-change diagnosis).",
433                count = self.exploration_since_progress
434            ));
435        }
436        None
437    }
438
439    fn repetition_warning(&mut self, tool_call: &ToolCall) -> Option<String> {
440        let Some(signature) = exploration_signature(tool_call) else {
441            self.repeated_exploration_count = 0;
442            self.last_exploration_signature = None;
443            return None;
444        };
445        if self.last_exploration_signature.as_deref() == Some(signature.as_str()) {
446            self.repeated_exploration_count += 1;
447        } else {
448            self.repeated_exploration_count = 1;
449            self.last_exploration_signature = Some(signature);
450        }
451        if self.repeated_exploration_count >= REPEATED_EXPLORATION_THRESHOLD {
452            self.warning_count += 1;
453            self.repeated_exploration_count = 0;
454            return Some(
455                "progress_guard: repeated the same investigation target without an intervening edit or validation. Use the evidence already gathered, state the hypothesis, or switch to a decisive test/change."
456                    .to_string(),
457            );
458        }
459        None
460    }
461}
462
463struct ProgressGuardHook {
464    state: Arc<Mutex<ProgressGuardState>>,
465}
466
467#[async_trait]
468impl PostToolExecHook for ProgressGuardHook {
469    fn priority(&self) -> PostToolExecHookPriority {
470        PostToolExecHookPriority::Normal
471    }
472
473    async fn after_exec(
474        &self,
475        tool_call: &ToolCall,
476        _tool_def: &ToolDefinition,
477        result: &mut ToolResult,
478        context: &ToolContext,
479    ) {
480        let warning = {
481            let mut state = self.state.lock().expect("progress guard state poisoned");
482            let progress = state
483                .sessions
484                .entry(context.session_id.to_string())
485                .or_default();
486            progress.observe(tool_call, result)
487        };
488
489        if let Some(warning) = warning {
490            inject_warning(result, warning);
491        }
492    }
493}
494
495#[derive(Clone, Copy)]
496struct ExplorationEvidence {
497    zero_matches: bool,
498    truncated: bool,
499}
500
501fn exploration_evidence(tool_call: &ToolCall, result: &ToolResult) -> Option<ExplorationEvidence> {
502    if result.error.is_some() {
503        return None;
504    }
505    if !matches!(
506        tool_call.name.as_str(),
507        "grep_files" | "repo_map" | "ast_grep"
508    ) {
509        return None;
510    }
511    let value = result.result.as_ref()?;
512    let count = value
513        .get("count")
514        .or_else(|| value.get("match_count"))
515        .and_then(Value::as_u64)?;
516    Some(ExplorationEvidence {
517        zero_matches: count == 0,
518        truncated: value
519            .get("truncated")
520            .and_then(Value::as_bool)
521            .unwrap_or(false),
522    })
523}
524
525#[derive(Debug, PartialEq, Eq)]
526enum ToolClass {
527    Exploration,
528    Mutation,
529    Validation(String),
530    Status(String),
531    /// Checking on an external event (CI run, PR checks/reviews) or plain
532    /// sleeping — the poll-loop shape that should instead be one detached
533    /// `spawn_background` watch.
534    Waiting,
535    Other,
536}
537
538fn classify_tool_call(tool_call: &ToolCall) -> ToolClass {
539    match tool_call.name.as_str() {
540        "read_file" | "grep_files" | "repo_map" | "search_sessions" | "ast_grep"
541        | "list_directory" | "stat_file" => ToolClass::Exploration,
542        "write_file" | "edit_file" | "delete_file" | "ast_edit" => ToolClass::Mutation,
543        "get_task" | "list_tasks" => ToolClass::Waiting,
544        "bash" => classify_bash_command(
545            tool_call
546                .arguments
547                .get("command")
548                .and_then(Value::as_str)
549                .unwrap_or_default(),
550        ),
551        _ => ToolClass::Other,
552    }
553}
554
555fn classify_bash_command(command: &str) -> ToolClass {
556    let normalized = normalize_command(command);
557    if normalized.is_empty() {
558        return ToolClass::Other;
559    }
560    // A leading `sleep N && …` is a poll delay: classify the probed command
561    // (`sleep 5 && cargo test` is still validation). A bare `sleep` is pure
562    // waiting.
563    if let Some(tail) = poll_delay_tail(&normalized) {
564        if tail.is_empty() {
565            return ToolClass::Waiting;
566        }
567        return classify_bash_command(tail);
568    }
569    if is_waiting_command(&normalized) {
570        return ToolClass::Waiting;
571    }
572    if is_status_command(&normalized) {
573        return ToolClass::Status(normalized);
574    }
575    if is_validation_command(&normalized) {
576        return ToolClass::Validation(normalized);
577    }
578    if is_mutating_command(&normalized) {
579        return ToolClass::Mutation;
580    }
581    if is_exploration_command(&normalized) {
582        return ToolClass::Exploration;
583    }
584    ToolClass::Other
585}
586
587struct MutationHashTransition {
588    path: String,
589    previous_hash: Option<String>,
590    content_hash: String,
591}
592
593fn mutation_hash_transition(
594    tool_call: &ToolCall,
595    result: &ToolResult,
596) -> Option<MutationHashTransition> {
597    if !matches!(tool_call.name.as_str(), "edit_file" | "write_file") {
598        return None;
599    }
600    let value = result.result.as_ref()?;
601    Some(MutationHashTransition {
602        path: value.get("path")?.as_str()?.to_string(),
603        previous_hash: value
604            .get("previous_content_hash")
605            .and_then(Value::as_str)
606            .map(str::to_string),
607        content_hash: value.get("content_hash")?.as_str()?.to_string(),
608    })
609}
610
611fn mutation_was_applied(tool_call: &ToolCall, result: &ToolResult) -> bool {
612    if tool_call.name == "ast_edit" {
613        return result
614            .result
615            .as_ref()
616            .and_then(|value| value.get("applied"))
617            .and_then(Value::as_bool)
618            .unwrap_or(false);
619    }
620    true
621}
622
623fn remember_bounded<T: Clone + Eq + Hash>(
624    seen: &mut HashSet<T>,
625    recent: &mut VecDeque<T>,
626    value: T,
627) {
628    seen.insert(value.clone());
629    recent.push_back(value);
630    if recent.len() > SEMANTIC_HISTORY_LIMIT
631        && let Some(expired) = recent.pop_front()
632    {
633        seen.remove(&expired);
634    }
635}
636
637/// For a command starting with `sleep`, everything after the first `&&`/`;`
638/// (empty when the sleep is the whole command). `None` when the command does
639/// not start with a sleep.
640fn poll_delay_tail(command: &str) -> Option<&str> {
641    let rest = command.strip_prefix("sleep")?;
642    if !rest.is_empty() && !rest.starts_with(' ') {
643        return None;
644    }
645    let tail = rest
646        .find("&&")
647        .map(|at| &rest[at + 2..])
648        .or_else(|| rest.find(';').map(|at| &rest[at + 1..]))
649        .unwrap_or("");
650    Some(tail.trim())
651}
652
653fn normalize_command(command: &str) -> String {
654    command.split_whitespace().collect::<Vec<_>>().join(" ")
655}
656
657fn exploration_signature(tool_call: &ToolCall) -> Option<String> {
658    match tool_call.name.as_str() {
659        "read_file" => {
660            let path = tool_call.arguments.get("path").and_then(Value::as_str)?;
661            let offset = tool_call
662                .arguments
663                .get("offset")
664                .and_then(Value::as_i64)
665                .unwrap_or(0);
666            Some(format!("read_file:{path}:{offset}"))
667        }
668        "grep_files" => {
669            let pattern = tool_call
670                .arguments
671                .get("pattern")
672                .and_then(Value::as_str)
673                .unwrap_or_default();
674            let path_pattern = tool_call
675                .arguments
676                .get("path_pattern")
677                .and_then(Value::as_str)
678                .unwrap_or_default();
679            Some(format!("grep_files:{path_pattern}:{pattern}"))
680        }
681        "repo_map" | "search_sessions" | "ast_grep" | "list_directory" | "stat_file" => {
682            Some(format!(
683                "{}:{}",
684                tool_call.name,
685                normalize_value(&tool_call.arguments)
686            ))
687        }
688        "bash" => {
689            let command = tool_call
690                .arguments
691                .get("command")
692                .and_then(Value::as_str)
693                .map(normalize_command)
694                .unwrap_or_default();
695            is_exploration_command(&command).then(|| format!("bash:{command}"))
696        }
697        _ => None,
698    }
699}
700
701fn normalize_value(value: &Value) -> String {
702    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
703}
704
705fn is_waiting_command(command: &str) -> bool {
706    let prefixes = [
707        "gh pr checks",
708        "gh pr status",
709        "gh pr view",
710        "gh run list",
711        "gh run view",
712        "gh run watch",
713        "gh workflow view",
714    ];
715    prefixes.iter().any(|prefix| command.starts_with(prefix))
716}
717
718fn is_status_command(command: &str) -> bool {
719    matches!(
720        command,
721        "git status" | "git status --short" | "git status --short --branch" | "git diff"
722    ) || command.starts_with("git diff ")
723        || command.starts_with("git status ")
724}
725
726fn is_validation_command(command: &str) -> bool {
727    let prefixes = [
728        "cargo test",
729        "cargo check",
730        "cargo build",
731        "cargo clippy",
732        "cargo fmt --check",
733        "npm test",
734        "npm run test",
735        "pnpm test",
736        "pnpm run test",
737        "yarn test",
738        "pytest",
739        "uv run",
740        "go test",
741        "python -m unittest",
742    ];
743    prefixes.iter().any(|prefix| command.starts_with(prefix))
744}
745
746fn is_mutating_command(command: &str) -> bool {
747    let tokens = [
748        "apply_patch",
749        "cargo fmt",
750        "cargo update",
751        "cargo generate-lockfile",
752        "cargo add",
753        "cargo remove",
754        "cargo fix",
755        "npm run format",
756        "pnpm run format",
757        "git apply",
758        "git commit",
759        "git add",
760        "mv ",
761        "cp ",
762        "rm ",
763        "mkdir ",
764    ];
765    tokens.iter().any(|token| command.contains(token))
766}
767
768fn is_exploration_command(command: &str) -> bool {
769    let prefixes = [
770        "rg ",
771        "grep ",
772        "find ",
773        "sed ",
774        "cat ",
775        "ls",
776        "git show",
777        "git log",
778        "git blame",
779        "git grep",
780        "git ls-files",
781    ];
782    prefixes.iter().any(|prefix| command.starts_with(prefix))
783}
784
785fn inject_warning(result: &mut ToolResult, warning: String) {
786    let mut object = match result.result.take() {
787        Some(Value::Object(object)) => object,
788        Some(value) => {
789            let mut object = Map::new();
790            object.insert("result".to_string(), value);
791            object
792        }
793        None => Map::new(),
794    };
795    object.insert("progress_guard_warning".to_string(), json!(warning));
796    result.result = Some(Value::Object(object));
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy, ToolResult};
803    use crate::typed_id::SessionId;
804
805    fn call(name: &str, arguments: Value) -> ToolCall {
806        ToolCall {
807            id: format!("call-{name}"),
808            name: name.to_string(),
809            arguments,
810        }
811    }
812
813    fn tool_def(name: &str) -> ToolDefinition {
814        ToolDefinition::Builtin(BuiltinTool {
815            name: name.to_string(),
816            display_name: None,
817            description: "test".to_string(),
818            parameters: json!({ "type": "object" }),
819            policy: ToolPolicy::Auto,
820            category: None,
821            deferrable: DeferrablePolicy::Never,
822            hints: ToolHints::default(),
823            full_parameters: None,
824        })
825    }
826
827    fn result() -> ToolResult {
828        ToolResult {
829            tool_call_id: "call".to_string(),
830            result: Some(json!({ "ok": true })),
831            images: None,
832            error: None,
833            connection_required: None,
834            raw_output: None,
835        }
836    }
837
838    fn result_value(value: Value) -> ToolResult {
839        ToolResult {
840            result: Some(value),
841            ..result()
842        }
843    }
844
845    #[tokio::test]
846    async fn unchanged_large_read_returns_compact_marker_and_mutation_invalidates_it() {
847        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
848        let hook = ProgressGuardHook { state };
849        let context = ToolContext::new(SessionId::new());
850        let read = call("read_file", json!({ "path": "/src/lib.rs" }));
851        let payload = json!({ "content": "discovery evidence\n".repeat(400) });
852
853        let mut first = result_value(payload.clone());
854        hook.after_exec(&read, &tool_def("read_file"), &mut first, &context)
855            .await;
856        let first_bytes = serde_json::to_vec(first.result.as_ref().unwrap())
857            .unwrap()
858            .len();
859
860        let mut repeated = result_value(payload.clone());
861        hook.after_exec(&read, &tool_def("read_file"), &mut repeated, &context)
862            .await;
863        let repeated_value = repeated.result.as_ref().unwrap();
864        let repeated_bytes = serde_json::to_vec(repeated_value).unwrap().len();
865        assert_eq!(repeated_value["unchanged_since_last_read"], true);
866        assert!(
867            repeated_value["progress_guard_warning"]
868                .as_str()
869                .is_some_and(|warning| warning.contains("earlier full result"))
870        );
871        assert!(
872            repeated_bytes * 10 < first_bytes,
873            "compact unchanged marker should materially cut context bytes: {repeated_bytes} vs {first_bytes}"
874        );
875
876        let mut write = result();
877        hook.after_exec(
878            &call(
879                "write_file",
880                json!({ "path": "/src/lib.rs", "content": "changed" }),
881            ),
882            &tool_def("write_file"),
883            &mut write,
884            &context,
885        )
886        .await;
887        let mut after_mutation = result_value(payload);
888        hook.after_exec(&read, &tool_def("read_file"), &mut after_mutation, &context)
889            .await;
890        assert!(after_mutation.result.unwrap().get("content").is_some());
891    }
892
893    #[tokio::test]
894    async fn reuse_requires_the_same_target_and_same_result() {
895        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
896        let hook = ProgressGuardHook { state };
897        let context = ToolContext::new(SessionId::new());
898        let large = "x".repeat(2_000);
899
900        let cases = [
901            ("/a.rs", large.clone()),
902            ("/b.rs", large.clone()),
903            ("/a.rs", format!("{large} changed")),
904        ];
905        for (path, content) in cases {
906            let mut observed = result_value(json!({ "content": content }));
907            hook.after_exec(
908                &call("read_file", json!({ "path": path })),
909                &tool_def("read_file"),
910                &mut observed,
911                &context,
912            )
913            .await;
914            assert!(
915                observed
916                    .result
917                    .as_ref()
918                    .and_then(|value| value.get("unchanged_since_last_read"))
919                    .is_none(),
920                "different targets or changed bytes are not reusable"
921            );
922        }
923    }
924
925    #[test]
926    fn classify_bash_command_distinguishes_status_and_validation() {
927        assert_eq!(
928            classify_bash_command("git status --short --branch"),
929            ToolClass::Status("git status --short --branch".to_string())
930        );
931        assert_eq!(
932            classify_bash_command("cargo test --all-features"),
933            ToolClass::Validation("cargo test --all-features".to_string())
934        );
935        assert_eq!(
936            classify_bash_command("cargo check --all-features"),
937            ToolClass::Validation("cargo check --all-features".to_string())
938        );
939        assert_eq!(
940            classify_bash_command("cargo update -p everruns-core --precise 0.17.7"),
941            ToolClass::Mutation
942        );
943        assert_eq!(
944            classify_bash_command("rg progress_guard"),
945            ToolClass::Exploration
946        );
947    }
948
949    #[test]
950    fn classify_bash_command_detects_external_event_waits() {
951        assert_eq!(classify_bash_command("gh pr checks 42"), ToolClass::Waiting);
952        assert_eq!(
953            classify_bash_command("gh run list --branch main --limit 5"),
954            ToolClass::Waiting
955        );
956        assert_eq!(classify_bash_command("sleep 120"), ToolClass::Waiting);
957        assert_eq!(
958            classify_bash_command("sleep 30 && gh pr checks 42"),
959            ToolClass::Waiting
960        );
961        // A sleep in front of real work classifies as the work itself.
962        assert_eq!(
963            classify_bash_command("sleep 5 && cargo test --all-features"),
964            ToolClass::Validation("cargo test --all-features".to_string())
965        );
966        // `sleepwalk` must not parse as a sleep prefix.
967        assert_eq!(classify_bash_command("sleepwalk"), ToolClass::Other);
968    }
969
970    #[tokio::test]
971    async fn hook_warns_after_long_exploration_without_progress() {
972        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
973        let hook = ProgressGuardHook { state };
974        let context = ToolContext::new(SessionId::new());
975        let mut last = result();
976
977        for _ in 0..EXPLORATION_WITHOUT_PROGRESS_THRESHOLD {
978            last = result();
979            hook.after_exec(
980                &call("read_file", json!({ "path": "/src/lib.rs" })),
981                &tool_def("read_file"),
982                &mut last,
983                &context,
984            )
985            .await;
986        }
987
988        assert!(
989            last.result
990                .as_ref()
991                .and_then(|value| value.get("progress_guard_warning"))
992                .and_then(Value::as_str)
993                .is_some_and(|warning| warning.contains("investigation tools"))
994        );
995    }
996
997    #[tokio::test]
998    async fn hook_escalates_to_checkpoint_after_more_exploration() {
999        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1000        let hook = ProgressGuardHook { state };
1001        let context = ToolContext::new(SessionId::new());
1002        let mut last = result();
1003
1004        for i in 0..CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1005            last = result();
1006            hook.after_exec(
1007                &call("read_file", json!({ "path": format!("/src/{i}.rs") })),
1008                &tool_def("read_file"),
1009                &mut last,
1010                &context,
1011            )
1012            .await;
1013        }
1014
1015        assert!(
1016            last.result
1017                .as_ref()
1018                .and_then(|value| value.get("progress_guard_warning"))
1019                .and_then(Value::as_str)
1020                .is_some_and(|warning| warning.contains("checkpoint required"))
1021        );
1022    }
1023
1024    #[tokio::test]
1025    async fn hook_keeps_warning_after_checkpoint_until_progress() {
1026        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1027        let hook = ProgressGuardHook { state };
1028        let context = ToolContext::new(SessionId::new());
1029        let mut last = result();
1030
1031        for i in 0..=CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1032            last = result();
1033            hook.after_exec(
1034                &call("grep_files", json!({ "pattern": format!("needle{i}") })),
1035                &tool_def("grep_files"),
1036                &mut last,
1037                &context,
1038            )
1039            .await;
1040        }
1041
1042        assert!(
1043            last.result
1044                .as_ref()
1045                .and_then(|value| value.get("progress_guard_warning"))
1046                .and_then(Value::as_str)
1047                .is_some_and(|warning| warning.contains("checkpoint required"))
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn mutation_resets_exploration_warning_counter() {
1053        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1054        let hook = ProgressGuardHook { state };
1055        let context = ToolContext::new(SessionId::new());
1056        let mut last = result();
1057
1058        for _ in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1059            hook.after_exec(
1060                &call("read_file", json!({ "path": "/src/lib.rs" })),
1061                &tool_def("read_file"),
1062                &mut result(),
1063                &context,
1064            )
1065            .await;
1066        }
1067        hook.after_exec(
1068            &call("edit_file", json!({ "path": "/src/lib.rs" })),
1069            &tool_def("edit_file"),
1070            &mut result(),
1071            &context,
1072        )
1073        .await;
1074        for _ in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1075            last = result();
1076            hook.after_exec(
1077                &call("read_file", json!({ "path": "/src/lib.rs" })),
1078                &tool_def("read_file"),
1079                &mut last,
1080                &context,
1081            )
1082            .await;
1083        }
1084
1085        assert!(
1086            last.result
1087                .as_ref()
1088                .and_then(|value| value.get("progress_guard_warning"))
1089                .is_none()
1090        );
1091    }
1092
1093    #[tokio::test]
1094    async fn validation_resets_checkpoint_warning_counter() {
1095        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1096        let hook = ProgressGuardHook { state };
1097        let context = ToolContext::new(SessionId::new());
1098        let mut last = result();
1099
1100        for i in 0..CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1101            hook.after_exec(
1102                &call("read_file", json!({ "path": format!("/src/{i}.rs") })),
1103                &tool_def("read_file"),
1104                &mut result(),
1105                &context,
1106            )
1107            .await;
1108        }
1109        hook.after_exec(
1110            &call("bash", json!({ "command": "cargo test --all-features" })),
1111            &tool_def("bash"),
1112            &mut result(),
1113            &context,
1114        )
1115        .await;
1116        for i in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1117            last = result();
1118            hook.after_exec(
1119                &call("read_file", json!({ "path": format!("/src/after/{i}.rs") })),
1120                &tool_def("read_file"),
1121                &mut last,
1122                &context,
1123            )
1124            .await;
1125        }
1126
1127        assert!(
1128            last.result
1129                .as_ref()
1130                .and_then(|value| value.get("progress_guard_warning"))
1131                .is_none()
1132        );
1133    }
1134
1135    #[tokio::test]
1136    async fn workspace_state_revisit_warns_on_a_mutation_cycle() {
1137        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1138        let hook = ProgressGuardHook { state };
1139        let context = ToolContext::new(SessionId::new());
1140        let transitions = [("A", "B"), ("B", "C"), ("C", "A")];
1141
1142        for (index, (previous, current)) in transitions.into_iter().enumerate() {
1143            let mut out = result_value(json!({
1144                "path": "/workspace/Cargo.toml",
1145                "previous_content_hash": previous,
1146                "content_hash": current,
1147            }));
1148            hook.after_exec(
1149                &call("edit_file", json!({ "path": "/workspace/Cargo.toml" })),
1150                &tool_def("edit_file"),
1151                &mut out,
1152                &context,
1153            )
1154            .await;
1155
1156            let warning = out
1157                .result
1158                .as_ref()
1159                .and_then(|value| value.get("progress_guard_warning"))
1160                .and_then(Value::as_str);
1161            if index < 2 {
1162                assert!(warning.is_none(), "new states are progress");
1163            } else {
1164                assert!(
1165                    warning.is_some_and(|text| text.contains("workspace state")),
1166                    "returning to A should expose the mutation cycle"
1167                );
1168            }
1169        }
1170    }
1171
1172    #[tokio::test]
1173    async fn lockfile_updates_do_not_hide_a_manifest_state_cycle() {
1174        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1175        let hook = ProgressGuardHook { state };
1176        let context = ToolContext::new(SessionId::new());
1177        let transitions = [("A", "B"), ("B", "C"), ("C", "A")];
1178
1179        for (index, (previous, current)) in transitions.into_iter().enumerate() {
1180            let mut edit = result_value(json!({
1181                "path": "/workspace/Cargo.toml",
1182                "previous_content_hash": previous,
1183                "content_hash": current,
1184            }));
1185            hook.after_exec(
1186                &call("edit_file", json!({ "path": "/workspace/Cargo.toml" })),
1187                &tool_def("edit_file"),
1188                &mut edit,
1189                &context,
1190            )
1191            .await;
1192
1193            let warning = edit
1194                .result
1195                .as_ref()
1196                .and_then(|value| value.get("progress_guard_warning"))
1197                .and_then(Value::as_str);
1198            if index < 2 {
1199                assert!(warning.is_none());
1200            } else {
1201                assert!(warning.is_some_and(|text| text.contains("workspace state")));
1202            }
1203
1204            let mut update = result();
1205            hook.after_exec(
1206                &call(
1207                    "bash",
1208                    json!({ "command": "cargo update -p everruns-core --precise 0.17.7" }),
1209                ),
1210                &tool_def("bash"),
1211                &mut update,
1212                &context,
1213            )
1214            .await;
1215        }
1216    }
1217
1218    #[tokio::test]
1219    async fn lockfile_update_makes_repeated_validation_fresh() {
1220        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1221        let hook = ProgressGuardHook { state };
1222        let context = ToolContext::new(SessionId::new());
1223        let validation = call("bash", json!({ "command": "cargo check" }));
1224
1225        hook.after_exec(&validation, &tool_def("bash"), &mut result(), &context)
1226            .await;
1227        hook.after_exec(
1228            &call(
1229                "bash",
1230                json!({ "command": "cargo update -p everruns-core --precise 0.17.7" }),
1231            ),
1232            &tool_def("bash"),
1233            &mut result(),
1234            &context,
1235        )
1236        .await;
1237
1238        let mut after_update = result();
1239        hook.after_exec(&validation, &tool_def("bash"), &mut after_update, &context)
1240            .await;
1241        assert!(
1242            after_update
1243                .result
1244                .as_ref()
1245                .and_then(|value| value.get("progress_guard_warning"))
1246                .is_none(),
1247            "validation after a lockfile mutation has new workspace evidence"
1248        );
1249
1250        let mut unchanged_again = result();
1251        hook.after_exec(
1252            &validation,
1253            &tool_def("bash"),
1254            &mut unchanged_again,
1255            &context,
1256        )
1257        .await;
1258        assert!(
1259            unchanged_again
1260                .result
1261                .as_ref()
1262                .and_then(|value| value.get("progress_guard_warning"))
1263                .is_some(),
1264            "a second validation without another mutation is redundant"
1265        );
1266    }
1267
1268    #[tokio::test]
1269    async fn repeated_validation_on_unchanged_state_warns() {
1270        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1271        let hook = ProgressGuardHook { state };
1272        let context = ToolContext::new(SessionId::new());
1273        let validation = call("bash", json!({ "command": "cargo test" }));
1274
1275        let mut first = result();
1276        hook.after_exec(&validation, &tool_def("bash"), &mut first, &context)
1277            .await;
1278        assert!(
1279            first
1280                .result
1281                .as_ref()
1282                .and_then(|value| value.get("progress_guard_warning"))
1283                .is_none()
1284        );
1285
1286        let mut repeated = result();
1287        hook.after_exec(&validation, &tool_def("bash"), &mut repeated, &context)
1288            .await;
1289        assert!(
1290            repeated
1291                .result
1292                .as_ref()
1293                .and_then(|value| value.get("progress_guard_warning"))
1294                .and_then(Value::as_str)
1295                .is_some_and(|text| text.contains("unchanged workspace state"))
1296        );
1297
1298        let mut edit = result_value(json!({
1299            "path": "/workspace/src/lib.rs",
1300            "previous_content_hash": "A",
1301            "content_hash": "B",
1302        }));
1303        hook.after_exec(
1304            &call("edit_file", json!({ "path": "/workspace/src/lib.rs" })),
1305            &tool_def("edit_file"),
1306            &mut edit,
1307            &context,
1308        )
1309        .await;
1310
1311        let mut after_progress = result();
1312        hook.after_exec(
1313            &validation,
1314            &tool_def("bash"),
1315            &mut after_progress,
1316            &context,
1317        )
1318        .await;
1319        assert!(
1320            after_progress
1321                .result
1322                .as_ref()
1323                .and_then(|value| value.get("progress_guard_warning"))
1324                .is_none(),
1325            "the same validation is useful after the workspace changes"
1326        );
1327    }
1328
1329    #[tokio::test]
1330    async fn semantic_progress_has_no_fixed_session_iteration_limit() {
1331        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1332        let hook = ProgressGuardHook { state };
1333        let context = ToolContext::new(SessionId::new());
1334
1335        for index in 0..256 {
1336            let mut out = result_value(json!({
1337                "path": "/workspace/src/lib.rs",
1338                "previous_content_hash": format!("state-{index}"),
1339                "content_hash": format!("state-{}", index + 1),
1340            }));
1341            hook.after_exec(
1342                &call("edit_file", json!({ "path": "/workspace/src/lib.rs" })),
1343                &tool_def("edit_file"),
1344                &mut out,
1345                &context,
1346            )
1347            .await;
1348            assert!(
1349                out.result
1350                    .as_ref()
1351                    .and_then(|value| value.get("progress_guard_warning"))
1352                    .is_none(),
1353                "each new state remains progress after iteration {index}"
1354            );
1355        }
1356    }
1357
1358    #[tokio::test]
1359    async fn repeated_exploration_target_warns() {
1360        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1361        let hook = ProgressGuardHook { state };
1362        let context = ToolContext::new(SessionId::new());
1363        let mut last = result();
1364
1365        for _ in 0..REPEATED_EXPLORATION_THRESHOLD {
1366            last = result();
1367            hook.after_exec(
1368                &call("read_file", json!({ "path": "/src/lib.rs", "offset": 10 })),
1369                &tool_def("read_file"),
1370                &mut last,
1371                &context,
1372            )
1373            .await;
1374        }
1375
1376        assert!(
1377            last.result
1378                .as_ref()
1379                .and_then(|value| value.get("progress_guard_warning"))
1380                .and_then(Value::as_str)
1381                .is_some_and(|warning| warning.contains("same investigation target"))
1382        );
1383    }
1384
1385    #[tokio::test]
1386    async fn three_zero_evidence_searches_warn_even_when_queries_differ() {
1387        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1388        let hook = ProgressGuardHook { state };
1389        let context = ToolContext::new(SessionId::new());
1390        let mut last = result();
1391
1392        for pattern in ["history", "ground", "session"] {
1393            last = result_value(json!({ "ok": true, "count": 0, "matches": [] }));
1394            hook.after_exec(
1395                &call("grep_files", json!({ "pattern": pattern })),
1396                &tool_def("grep_files"),
1397                &mut last,
1398                &context,
1399            )
1400            .await;
1401        }
1402
1403        assert!(
1404            last.result
1405                .as_ref()
1406                .and_then(|value| value.get("progress_guard_warning"))
1407                .and_then(Value::as_str)
1408                .is_some_and(|warning| warning.contains("zero matches"))
1409        );
1410    }
1411
1412    #[tokio::test]
1413    async fn positive_search_evidence_resets_zero_result_streak() {
1414        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1415        let hook = ProgressGuardHook { state };
1416        let context = ToolContext::new(SessionId::new());
1417
1418        for count in [0, 0, 1, 0, 0] {
1419            let mut out = result_value(json!({ "ok": true, "count": count }));
1420            hook.after_exec(
1421                &call("repo_map", json!({ "query": format!("query-{count}") })),
1422                &tool_def("repo_map"),
1423                &mut out,
1424                &context,
1425            )
1426            .await;
1427            assert!(
1428                out.result
1429                    .as_ref()
1430                    .and_then(|value| value.get("progress_guard_warning"))
1431                    .is_none(),
1432                "a positive result should break the zero-evidence streak"
1433            );
1434        }
1435    }
1436
1437    #[tokio::test]
1438    async fn repeated_truncated_exploration_warns_to_narrow_scope() {
1439        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1440        let hook = ProgressGuardHook { state };
1441        let context = ToolContext::new(SessionId::new());
1442        let mut last = result();
1443
1444        for query in ["runtime", "capability"] {
1445            last = result_value(json!({ "ok": true, "count": 50, "truncated": true }));
1446            hook.after_exec(
1447                &call("repo_map", json!({ "query": query })),
1448                &tool_def("repo_map"),
1449                &mut last,
1450                &context,
1451            )
1452            .await;
1453        }
1454
1455        assert!(
1456            last.result
1457                .as_ref()
1458                .and_then(|value| value.get("progress_guard_warning"))
1459                .and_then(Value::as_str)
1460                .is_some_and(|warning| warning.contains("truncated"))
1461        );
1462    }
1463
1464    #[tokio::test]
1465    async fn original_session_pattern_gets_interrupted_before_runaway_reads() {
1466        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1467        let hook = ProgressGuardHook { state };
1468        let context = ToolContext::new(SessionId::new());
1469        let mut warnings = Vec::new();
1470
1471        let observe = |tool_call: ToolCall| {
1472            let hook = &hook;
1473            let context = &context;
1474            async move {
1475                let mut out = result();
1476                hook.after_exec(&tool_call, &tool_def(&tool_call.name), &mut out, context)
1477                    .await;
1478                out.result
1479                    .as_ref()
1480                    .and_then(|value| value.get("progress_guard_warning"))
1481                    .and_then(Value::as_str)
1482                    .map(str::to_string)
1483            }
1484        };
1485
1486        // Same shape as the failed investigation: broad searches, then many
1487        // repeated reads of the callback-adjacent files, with no edit/test.
1488        let broad_searches = [
1489            "scheduled|schedule|signal_on_completion|callback|background",
1490            "spawn_background|signal_on_completion|scheduled_at",
1491            "cron|completion|task|background_run|Background|Task",
1492            "drain_finished_for_wake|wake_prompt|BackgroundRegistry",
1493            "SessionScheduleStore|schedule_store|create_schedule",
1494            "maybe_wake_for_background|TaskRegistryEvent",
1495        ];
1496        for pattern in broad_searches {
1497            if let Some(warning) = observe(call(
1498                "grep_files",
1499                json!({ "pattern": pattern, "path_pattern": "src/**/*.rs" }),
1500            ))
1501            .await
1502            {
1503                warnings.push(warning);
1504            }
1505        }
1506        for offset in [180, 388, 633, 940, 1370, 1540, 180, 388, 633] {
1507            if let Some(warning) = observe(call(
1508                "read_file",
1509                json!({ "path": "/repo/src/capabilities/background.rs", "offset": offset }),
1510            ))
1511            .await
1512            {
1513                warnings.push(warning);
1514            }
1515        }
1516        for offset in [1000, 1020, 1010, 1028, 1000, 1020, 1010, 1028, 1000] {
1517            if let Some(warning) = observe(call(
1518                "read_file",
1519                json!({ "path": "/repo/src/app/mod.rs", "offset": offset }),
1520            ))
1521            .await
1522            {
1523                warnings.push(warning);
1524            }
1525        }
1526        assert!(
1527            warnings
1528                .iter()
1529                .any(|warning| warning.contains("investigation tools")),
1530            "first threshold should warn before the session keeps circling: {warnings:?}"
1531        );
1532
1533        for i in 0..24 {
1534            if let Some(warning) = observe(call(
1535                "read_file",
1536                json!({ "path": "/repo/src/runtime.rs", "offset": 2100 + i }),
1537            ))
1538            .await
1539            {
1540                warnings.push(warning);
1541            }
1542        }
1543        assert!(
1544            warnings
1545                .iter()
1546                .any(|warning| warning.contains("checkpoint required")),
1547            "checkpoint escalation should trigger by 48 read/search calls: {warnings:?}"
1548        );
1549
1550        for _ in 0..REPEATED_EXPLORATION_THRESHOLD {
1551            if let Some(warning) = observe(call(
1552                "read_file",
1553                json!({ "path": "/repo/src/app/mod.rs", "offset": 1000 }),
1554            ))
1555            .await
1556            {
1557                warnings.push(warning);
1558            }
1559        }
1560        assert!(
1561            warnings
1562                .iter()
1563                .any(|warning| warning.contains("same investigation target")),
1564            "semantic repetition should catch rereading the same range: {warnings:?}"
1565        );
1566    }
1567
1568    #[tokio::test]
1569    async fn repeated_ci_polling_warns_toward_spawn_background() {
1570        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1571        let hook = ProgressGuardHook { state };
1572        let context = ToolContext::new(SessionId::new());
1573        let mut last = result();
1574
1575        // The classic poll loop: delay-then-check, over and over. Different
1576        // probe commands still count — polling rarely repeats verbatim.
1577        let polls = [
1578            "gh pr checks 42",
1579            "sleep 30 && gh pr checks 42",
1580            "gh run list --limit 1",
1581            "gh pr view 42",
1582        ];
1583        for command in polls {
1584            last = result();
1585            hook.after_exec(
1586                &call("bash", json!({ "command": command })),
1587                &tool_def("bash"),
1588                &mut last,
1589                &context,
1590            )
1591            .await;
1592        }
1593
1594        assert!(
1595            last.result
1596                .as_ref()
1597                .and_then(|value| value.get("progress_guard_warning"))
1598                .and_then(Value::as_str)
1599                .is_some_and(|warning| warning.contains("spawn_background"))
1600        );
1601    }
1602
1603    #[tokio::test]
1604    async fn semantic_polling_cycle_warns_across_heterogeneous_tools() {
1605        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1606        let hook = ProgressGuardHook { state };
1607        let context = ToolContext::new(SessionId::new());
1608        let cycle = [
1609            call("bash", json!({ "command": "gh pr checks 42" })),
1610            call("read_file", json!({ "path": "/tmp/synthetic-ci-note" })),
1611            call("get_task", json!({ "task_id": "task_ci" })),
1612            call("bash", json!({ "command": "gh run view 123" })),
1613        ];
1614        let mut warnings = Vec::new();
1615
1616        for tool_call in cycle.into_iter().cycle().take(8) {
1617            let mut output = result();
1618            hook.after_exec(
1619                &tool_call,
1620                &tool_def(&tool_call.name),
1621                &mut output,
1622                &context,
1623            )
1624            .await;
1625            if let Some(warning) = output
1626                .result
1627                .as_ref()
1628                .and_then(|value| value.get("progress_guard_warning"))
1629                .and_then(Value::as_str)
1630            {
1631                warnings.push(warning.to_string());
1632            }
1633        }
1634
1635        assert!(
1636            warnings
1637                .iter()
1638                .any(|warning| warning.contains("spawn_background")),
1639            "a semantic polling cycle must be caught even when unrelated reads and task probes separate external checks: {warnings:?}"
1640        );
1641    }
1642
1643    #[tokio::test]
1644    async fn one_off_task_and_ci_status_checks_do_not_warn() {
1645        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1646        let hook = ProgressGuardHook { state };
1647        let context = ToolContext::new(SessionId::new());
1648
1649        for tool_call in [
1650            call("list_tasks", json!({})),
1651            call("get_task", json!({ "task_id": "task_once" })),
1652            call("bash", json!({ "command": "gh pr checks 42" })),
1653        ] {
1654            let mut output = result();
1655            hook.after_exec(
1656                &tool_call,
1657                &tool_def(&tool_call.name),
1658                &mut output,
1659                &context,
1660            )
1661            .await;
1662            assert!(
1663                output
1664                    .result
1665                    .as_ref()
1666                    .and_then(|value| value.get("progress_guard_warning"))
1667                    .is_none(),
1668                "a one-off status check is legitimate"
1669            );
1670        }
1671    }
1672
1673    #[tokio::test]
1674    async fn interleaved_work_resets_waiting_window() {
1675        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1676        let hook = ProgressGuardHook { state };
1677        let context = ToolContext::new(SessionId::new());
1678
1679        // Check CI, fix something, check again — legitimate, because real
1680        // progress clears the semantic window.
1681        for _ in 0..(WAITING_WINDOW_THRESHOLD * 2) {
1682            let mut check = result();
1683            hook.after_exec(
1684                &call("bash", json!({ "command": "gh pr checks 42" })),
1685                &tool_def("bash"),
1686                &mut check,
1687                &context,
1688            )
1689            .await;
1690            assert!(
1691                check
1692                    .result
1693                    .as_ref()
1694                    .and_then(|value| value.get("progress_guard_warning"))
1695                    .is_none()
1696            );
1697            hook.after_exec(
1698                &call("edit_file", json!({ "path": "/src/lib.rs" })),
1699                &tool_def("edit_file"),
1700                &mut result(),
1701                &context,
1702            )
1703            .await;
1704        }
1705    }
1706
1707    #[tokio::test]
1708    async fn repeated_git_status_warns() {
1709        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1710        let hook = ProgressGuardHook { state };
1711        let context = ToolContext::new(SessionId::new());
1712        let mut last = result();
1713
1714        for _ in 0..REPEATED_STATUS_THRESHOLD {
1715            last = result();
1716            hook.after_exec(
1717                &call("bash", json!({ "command": "git status --short" })),
1718                &tool_def("bash"),
1719                &mut last,
1720                &context,
1721            )
1722            .await;
1723        }
1724
1725        assert!(
1726            last.result
1727                .as_ref()
1728                .and_then(|value| value.get("progress_guard_warning"))
1729                .and_then(Value::as_str)
1730                .is_some_and(|warning| warning.contains("repeated git status"))
1731        );
1732    }
1733}