Skip to main content

everruns_builtins/
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::capabilities::{Capability, CapabilityStatus};
8use crate::tool_hooks::{PostToolExecHook, PostToolExecHookPriority};
9use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
10use async_trait::async_trait;
11use everruns_core::tool_context::ToolContext;
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" | "read_many_files" | "grep_files" | "repo_map" | "search_sessions"
541        | "ast_grep" | "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("commands")
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        "read_many_files" | "repo_map" | "search_sessions" | "ast_grep" | "list_directory"
682        | "stat_file" => Some(format!(
683            "{}:{}",
684            tool_call.name,
685            normalize_value(&tool_call.arguments)
686        )),
687        "bash" => {
688            let command = tool_call
689                .arguments
690                .get("commands")
691                .and_then(Value::as_str)
692                .map(normalize_command)
693                .unwrap_or_default();
694            is_exploration_command(&command).then(|| format!("bash:{command}"))
695        }
696        _ => None,
697    }
698}
699
700fn normalize_value(value: &Value) -> String {
701    serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
702}
703
704fn is_waiting_command(command: &str) -> bool {
705    let prefixes = [
706        "gh pr checks",
707        "gh pr status",
708        "gh pr view",
709        "gh run list",
710        "gh run view",
711        "gh run watch",
712        "gh workflow view",
713    ];
714    prefixes.iter().any(|prefix| command.starts_with(prefix))
715}
716
717fn is_status_command(command: &str) -> bool {
718    matches!(
719        command,
720        "git status" | "git status --short" | "git status --short --branch" | "git diff"
721    ) || command.starts_with("git diff ")
722        || command.starts_with("git status ")
723}
724
725fn is_validation_command(command: &str) -> bool {
726    let prefixes = [
727        "cargo test",
728        "cargo check",
729        "cargo build",
730        "cargo clippy",
731        "cargo fmt --check",
732        "npm test",
733        "npm run test",
734        "pnpm test",
735        "pnpm run test",
736        "yarn test",
737        "pytest",
738        "uv run",
739        "go test",
740        "python -m unittest",
741    ];
742    prefixes.iter().any(|prefix| command.starts_with(prefix))
743}
744
745fn is_mutating_command(command: &str) -> bool {
746    let tokens = [
747        "apply_patch",
748        "cargo fmt",
749        "cargo update",
750        "cargo generate-lockfile",
751        "cargo add",
752        "cargo remove",
753        "cargo fix",
754        "npm run format",
755        "pnpm run format",
756        "git apply",
757        "git commit",
758        "git add",
759        "mv ",
760        "cp ",
761        "rm ",
762        "mkdir ",
763    ];
764    tokens.iter().any(|token| command.contains(token))
765}
766
767fn is_exploration_command(command: &str) -> bool {
768    let prefixes = [
769        "rg ",
770        "grep ",
771        "find ",
772        "sed ",
773        "cat ",
774        "ls",
775        "git show",
776        "git log",
777        "git blame",
778        "git grep",
779        "git ls-files",
780    ];
781    prefixes.iter().any(|prefix| command.starts_with(prefix))
782}
783
784fn inject_warning(result: &mut ToolResult, warning: String) {
785    let mut object = match result.result.take() {
786        Some(Value::Object(object)) => object,
787        Some(value) => {
788            let mut object = Map::new();
789            object.insert("result".to_string(), value);
790            object
791        }
792        None => Map::new(),
793    };
794    object.insert("progress_guard_warning".to_string(), json!(warning));
795    result.result = Some(Value::Object(object));
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy, ToolResult};
802    use crate::typed_id::SessionId;
803
804    fn call(name: &str, arguments: Value) -> ToolCall {
805        ToolCall {
806            id: format!("call-{name}"),
807            name: name.to_string(),
808            arguments,
809        }
810    }
811
812    fn tool_def(name: &str) -> ToolDefinition {
813        ToolDefinition::Builtin(BuiltinTool {
814            name: name.to_string(),
815            display_name: None,
816            description: "test".to_string(),
817            parameters: json!({ "type": "object" }),
818            policy: ToolPolicy::Auto,
819            category: None,
820            deferrable: DeferrablePolicy::Never,
821            hints: ToolHints::default(),
822            full_parameters: None,
823        })
824    }
825
826    fn result() -> ToolResult {
827        ToolResult {
828            tool_call_id: "call".to_string(),
829            result: Some(json!({ "ok": true })),
830            images: None,
831            error: None,
832            connection_required: None,
833            raw_output: None,
834        }
835    }
836
837    fn result_value(value: Value) -> ToolResult {
838        ToolResult {
839            result: Some(value),
840            ..result()
841        }
842    }
843
844    #[test]
845    fn batch_reads_are_bounded_exploration_with_stable_signatures() {
846        let read = call(
847            "read_many_files",
848            json!({"paths": ["/workspace/a", "/workspace/b"]}),
849        );
850
851        assert_eq!(classify_tool_call(&read), ToolClass::Exploration);
852        assert_eq!(
853            exploration_signature(&read).as_deref(),
854            Some("read_many_files:{\"paths\":[\"/workspace/a\",\"/workspace/b\"]}")
855        );
856    }
857
858    #[tokio::test]
859    async fn unchanged_large_read_returns_compact_marker_and_mutation_invalidates_it() {
860        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
861        let hook = ProgressGuardHook { state };
862        let context = ToolContext::new(SessionId::new());
863        let read = call("read_file", json!({ "path": "/src/lib.rs" }));
864        let payload = json!({ "content": "discovery evidence\n".repeat(400) });
865
866        let mut first = result_value(payload.clone());
867        hook.after_exec(&read, &tool_def("read_file"), &mut first, &context)
868            .await;
869        let first_bytes = serde_json::to_vec(first.result.as_ref().unwrap())
870            .unwrap()
871            .len();
872
873        let mut repeated = result_value(payload.clone());
874        hook.after_exec(&read, &tool_def("read_file"), &mut repeated, &context)
875            .await;
876        let repeated_value = repeated.result.as_ref().unwrap();
877        let repeated_bytes = serde_json::to_vec(repeated_value).unwrap().len();
878        assert_eq!(repeated_value["unchanged_since_last_read"], true);
879        assert!(
880            repeated_value["progress_guard_warning"]
881                .as_str()
882                .is_some_and(|warning| warning.contains("earlier full result"))
883        );
884        assert!(
885            repeated_bytes * 10 < first_bytes,
886            "compact unchanged marker should materially cut context bytes: {repeated_bytes} vs {first_bytes}"
887        );
888
889        let mut write = result();
890        hook.after_exec(
891            &call(
892                "write_file",
893                json!({ "path": "/src/lib.rs", "content": "changed" }),
894            ),
895            &tool_def("write_file"),
896            &mut write,
897            &context,
898        )
899        .await;
900        let mut after_mutation = result_value(payload);
901        hook.after_exec(&read, &tool_def("read_file"), &mut after_mutation, &context)
902            .await;
903        assert!(after_mutation.result.unwrap().get("content").is_some());
904    }
905
906    #[tokio::test]
907    async fn reuse_requires_the_same_target_and_same_result() {
908        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
909        let hook = ProgressGuardHook { state };
910        let context = ToolContext::new(SessionId::new());
911        let large = "x".repeat(2_000);
912
913        let cases = [
914            ("/a.rs", large.clone()),
915            ("/b.rs", large.clone()),
916            ("/a.rs", format!("{large} changed")),
917        ];
918        for (path, content) in cases {
919            let mut observed = result_value(json!({ "content": content }));
920            hook.after_exec(
921                &call("read_file", json!({ "path": path })),
922                &tool_def("read_file"),
923                &mut observed,
924                &context,
925            )
926            .await;
927            assert!(
928                observed
929                    .result
930                    .as_ref()
931                    .and_then(|value| value.get("unchanged_since_last_read"))
932                    .is_none(),
933                "different targets or changed bytes are not reusable"
934            );
935        }
936    }
937
938    #[test]
939    fn classify_bash_command_distinguishes_status_and_validation() {
940        assert_eq!(
941            classify_bash_command("git status --short --branch"),
942            ToolClass::Status("git status --short --branch".to_string())
943        );
944        assert_eq!(
945            classify_bash_command("cargo test --all-features"),
946            ToolClass::Validation("cargo test --all-features".to_string())
947        );
948        assert_eq!(
949            classify_bash_command("cargo check --all-features"),
950            ToolClass::Validation("cargo check --all-features".to_string())
951        );
952        assert_eq!(
953            classify_bash_command("cargo update -p everruns-core --precise 0.17.7"),
954            ToolClass::Mutation
955        );
956        assert_eq!(
957            classify_bash_command("rg progress_guard"),
958            ToolClass::Exploration
959        );
960    }
961
962    #[test]
963    fn classify_bash_command_detects_external_event_waits() {
964        assert_eq!(classify_bash_command("gh pr checks 42"), ToolClass::Waiting);
965        assert_eq!(
966            classify_bash_command("gh run list --branch main --limit 5"),
967            ToolClass::Waiting
968        );
969        assert_eq!(classify_bash_command("sleep 120"), ToolClass::Waiting);
970        assert_eq!(
971            classify_bash_command("sleep 30 && gh pr checks 42"),
972            ToolClass::Waiting
973        );
974        // A sleep in front of real work classifies as the work itself.
975        assert_eq!(
976            classify_bash_command("sleep 5 && cargo test --all-features"),
977            ToolClass::Validation("cargo test --all-features".to_string())
978        );
979        // `sleepwalk` must not parse as a sleep prefix.
980        assert_eq!(classify_bash_command("sleepwalk"), ToolClass::Other);
981    }
982
983    #[tokio::test]
984    async fn hook_warns_after_long_exploration_without_progress() {
985        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
986        let hook = ProgressGuardHook { state };
987        let context = ToolContext::new(SessionId::new());
988        let mut last = result();
989
990        for _ in 0..EXPLORATION_WITHOUT_PROGRESS_THRESHOLD {
991            last = result();
992            hook.after_exec(
993                &call("read_file", json!({ "path": "/src/lib.rs" })),
994                &tool_def("read_file"),
995                &mut last,
996                &context,
997            )
998            .await;
999        }
1000
1001        assert!(
1002            last.result
1003                .as_ref()
1004                .and_then(|value| value.get("progress_guard_warning"))
1005                .and_then(Value::as_str)
1006                .is_some_and(|warning| warning.contains("investigation tools"))
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn hook_escalates_to_checkpoint_after_more_exploration() {
1012        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1013        let hook = ProgressGuardHook { state };
1014        let context = ToolContext::new(SessionId::new());
1015        let mut last = result();
1016
1017        for i in 0..CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1018            last = result();
1019            hook.after_exec(
1020                &call("read_file", json!({ "path": format!("/src/{i}.rs") })),
1021                &tool_def("read_file"),
1022                &mut last,
1023                &context,
1024            )
1025            .await;
1026        }
1027
1028        assert!(
1029            last.result
1030                .as_ref()
1031                .and_then(|value| value.get("progress_guard_warning"))
1032                .and_then(Value::as_str)
1033                .is_some_and(|warning| warning.contains("checkpoint required"))
1034        );
1035    }
1036
1037    #[tokio::test]
1038    async fn hook_keeps_warning_after_checkpoint_until_progress() {
1039        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1040        let hook = ProgressGuardHook { state };
1041        let context = ToolContext::new(SessionId::new());
1042        let mut last = result();
1043
1044        for i in 0..=CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1045            last = result();
1046            hook.after_exec(
1047                &call("grep_files", json!({ "pattern": format!("needle{i}") })),
1048                &tool_def("grep_files"),
1049                &mut last,
1050                &context,
1051            )
1052            .await;
1053        }
1054
1055        assert!(
1056            last.result
1057                .as_ref()
1058                .and_then(|value| value.get("progress_guard_warning"))
1059                .and_then(Value::as_str)
1060                .is_some_and(|warning| warning.contains("checkpoint required"))
1061        );
1062    }
1063
1064    #[tokio::test]
1065    async fn mutation_resets_exploration_warning_counter() {
1066        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1067        let hook = ProgressGuardHook { state };
1068        let context = ToolContext::new(SessionId::new());
1069        let mut last = result();
1070
1071        for _ in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1072            hook.after_exec(
1073                &call("read_file", json!({ "path": "/src/lib.rs" })),
1074                &tool_def("read_file"),
1075                &mut result(),
1076                &context,
1077            )
1078            .await;
1079        }
1080        hook.after_exec(
1081            &call("edit_file", json!({ "path": "/src/lib.rs" })),
1082            &tool_def("edit_file"),
1083            &mut result(),
1084            &context,
1085        )
1086        .await;
1087        for _ in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1088            last = result();
1089            hook.after_exec(
1090                &call("read_file", json!({ "path": "/src/lib.rs" })),
1091                &tool_def("read_file"),
1092                &mut last,
1093                &context,
1094            )
1095            .await;
1096        }
1097
1098        assert!(
1099            last.result
1100                .as_ref()
1101                .and_then(|value| value.get("progress_guard_warning"))
1102                .is_none()
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn validation_resets_checkpoint_warning_counter() {
1108        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1109        let hook = ProgressGuardHook { state };
1110        let context = ToolContext::new(SessionId::new());
1111        let mut last = result();
1112
1113        for i in 0..CHECKPOINT_WITHOUT_PROGRESS_THRESHOLD {
1114            hook.after_exec(
1115                &call("read_file", json!({ "path": format!("/src/{i}.rs") })),
1116                &tool_def("read_file"),
1117                &mut result(),
1118                &context,
1119            )
1120            .await;
1121        }
1122        hook.after_exec(
1123            &call("bash", json!({ "commands": "cargo test --all-features" })),
1124            &tool_def("bash"),
1125            &mut result(),
1126            &context,
1127        )
1128        .await;
1129        for i in 0..(EXPLORATION_WITHOUT_PROGRESS_THRESHOLD - 1) {
1130            last = result();
1131            hook.after_exec(
1132                &call("read_file", json!({ "path": format!("/src/after/{i}.rs") })),
1133                &tool_def("read_file"),
1134                &mut last,
1135                &context,
1136            )
1137            .await;
1138        }
1139
1140        assert!(
1141            last.result
1142                .as_ref()
1143                .and_then(|value| value.get("progress_guard_warning"))
1144                .is_none()
1145        );
1146    }
1147
1148    #[tokio::test]
1149    async fn workspace_state_revisit_warns_on_a_mutation_cycle() {
1150        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1151        let hook = ProgressGuardHook { state };
1152        let context = ToolContext::new(SessionId::new());
1153        let transitions = [("A", "B"), ("B", "C"), ("C", "A")];
1154
1155        for (index, (previous, current)) in transitions.into_iter().enumerate() {
1156            let mut out = result_value(json!({
1157                "path": "/workspace/Cargo.toml",
1158                "previous_content_hash": previous,
1159                "content_hash": current,
1160            }));
1161            hook.after_exec(
1162                &call("edit_file", json!({ "path": "/workspace/Cargo.toml" })),
1163                &tool_def("edit_file"),
1164                &mut out,
1165                &context,
1166            )
1167            .await;
1168
1169            let warning = out
1170                .result
1171                .as_ref()
1172                .and_then(|value| value.get("progress_guard_warning"))
1173                .and_then(Value::as_str);
1174            if index < 2 {
1175                assert!(warning.is_none(), "new states are progress");
1176            } else {
1177                assert!(
1178                    warning.is_some_and(|text| text.contains("workspace state")),
1179                    "returning to A should expose the mutation cycle"
1180                );
1181            }
1182        }
1183    }
1184
1185    #[tokio::test]
1186    async fn lockfile_updates_do_not_hide_a_manifest_state_cycle() {
1187        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1188        let hook = ProgressGuardHook { state };
1189        let context = ToolContext::new(SessionId::new());
1190        let transitions = [("A", "B"), ("B", "C"), ("C", "A")];
1191
1192        for (index, (previous, current)) in transitions.into_iter().enumerate() {
1193            let mut edit = result_value(json!({
1194                "path": "/workspace/Cargo.toml",
1195                "previous_content_hash": previous,
1196                "content_hash": current,
1197            }));
1198            hook.after_exec(
1199                &call("edit_file", json!({ "path": "/workspace/Cargo.toml" })),
1200                &tool_def("edit_file"),
1201                &mut edit,
1202                &context,
1203            )
1204            .await;
1205
1206            let warning = edit
1207                .result
1208                .as_ref()
1209                .and_then(|value| value.get("progress_guard_warning"))
1210                .and_then(Value::as_str);
1211            if index < 2 {
1212                assert!(warning.is_none());
1213            } else {
1214                assert!(warning.is_some_and(|text| text.contains("workspace state")));
1215            }
1216
1217            let mut update = result();
1218            hook.after_exec(
1219                &call(
1220                    "bash",
1221                    json!({ "commands": "cargo update -p everruns-core --precise 0.17.7" }),
1222                ),
1223                &tool_def("bash"),
1224                &mut update,
1225                &context,
1226            )
1227            .await;
1228        }
1229    }
1230
1231    #[tokio::test]
1232    async fn lockfile_update_makes_repeated_validation_fresh() {
1233        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1234        let hook = ProgressGuardHook { state };
1235        let context = ToolContext::new(SessionId::new());
1236        let validation = call("bash", json!({ "commands": "cargo check" }));
1237
1238        hook.after_exec(&validation, &tool_def("bash"), &mut result(), &context)
1239            .await;
1240        hook.after_exec(
1241            &call(
1242                "bash",
1243                json!({ "commands": "cargo update -p everruns-core --precise 0.17.7" }),
1244            ),
1245            &tool_def("bash"),
1246            &mut result(),
1247            &context,
1248        )
1249        .await;
1250
1251        let mut after_update = result();
1252        hook.after_exec(&validation, &tool_def("bash"), &mut after_update, &context)
1253            .await;
1254        assert!(
1255            after_update
1256                .result
1257                .as_ref()
1258                .and_then(|value| value.get("progress_guard_warning"))
1259                .is_none(),
1260            "validation after a lockfile mutation has new workspace evidence"
1261        );
1262
1263        let mut unchanged_again = result();
1264        hook.after_exec(
1265            &validation,
1266            &tool_def("bash"),
1267            &mut unchanged_again,
1268            &context,
1269        )
1270        .await;
1271        assert!(
1272            unchanged_again
1273                .result
1274                .as_ref()
1275                .and_then(|value| value.get("progress_guard_warning"))
1276                .is_some(),
1277            "a second validation without another mutation is redundant"
1278        );
1279    }
1280
1281    #[tokio::test]
1282    async fn repeated_validation_on_unchanged_state_warns() {
1283        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1284        let hook = ProgressGuardHook { state };
1285        let context = ToolContext::new(SessionId::new());
1286        let validation = call("bash", json!({ "commands": "cargo test" }));
1287
1288        let mut first = result();
1289        hook.after_exec(&validation, &tool_def("bash"), &mut first, &context)
1290            .await;
1291        assert!(
1292            first
1293                .result
1294                .as_ref()
1295                .and_then(|value| value.get("progress_guard_warning"))
1296                .is_none()
1297        );
1298
1299        let mut repeated = result();
1300        hook.after_exec(&validation, &tool_def("bash"), &mut repeated, &context)
1301            .await;
1302        assert!(
1303            repeated
1304                .result
1305                .as_ref()
1306                .and_then(|value| value.get("progress_guard_warning"))
1307                .and_then(Value::as_str)
1308                .is_some_and(|text| text.contains("unchanged workspace state"))
1309        );
1310
1311        let mut edit = result_value(json!({
1312            "path": "/workspace/src/lib.rs",
1313            "previous_content_hash": "A",
1314            "content_hash": "B",
1315        }));
1316        hook.after_exec(
1317            &call("edit_file", json!({ "path": "/workspace/src/lib.rs" })),
1318            &tool_def("edit_file"),
1319            &mut edit,
1320            &context,
1321        )
1322        .await;
1323
1324        let mut after_progress = result();
1325        hook.after_exec(
1326            &validation,
1327            &tool_def("bash"),
1328            &mut after_progress,
1329            &context,
1330        )
1331        .await;
1332        assert!(
1333            after_progress
1334                .result
1335                .as_ref()
1336                .and_then(|value| value.get("progress_guard_warning"))
1337                .is_none(),
1338            "the same validation is useful after the workspace changes"
1339        );
1340    }
1341
1342    #[tokio::test]
1343    async fn semantic_progress_has_no_fixed_session_iteration_limit() {
1344        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1345        let hook = ProgressGuardHook { state };
1346        let context = ToolContext::new(SessionId::new());
1347
1348        for index in 0..256 {
1349            let mut out = result_value(json!({
1350                "path": "/workspace/src/lib.rs",
1351                "previous_content_hash": format!("state-{index}"),
1352                "content_hash": format!("state-{}", index + 1),
1353            }));
1354            hook.after_exec(
1355                &call("edit_file", json!({ "path": "/workspace/src/lib.rs" })),
1356                &tool_def("edit_file"),
1357                &mut out,
1358                &context,
1359            )
1360            .await;
1361            assert!(
1362                out.result
1363                    .as_ref()
1364                    .and_then(|value| value.get("progress_guard_warning"))
1365                    .is_none(),
1366                "each new state remains progress after iteration {index}"
1367            );
1368        }
1369    }
1370
1371    #[tokio::test]
1372    async fn repeated_exploration_target_warns() {
1373        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1374        let hook = ProgressGuardHook { state };
1375        let context = ToolContext::new(SessionId::new());
1376        let mut last = result();
1377
1378        for _ in 0..REPEATED_EXPLORATION_THRESHOLD {
1379            last = result();
1380            hook.after_exec(
1381                &call("read_file", json!({ "path": "/src/lib.rs", "offset": 10 })),
1382                &tool_def("read_file"),
1383                &mut last,
1384                &context,
1385            )
1386            .await;
1387        }
1388
1389        assert!(
1390            last.result
1391                .as_ref()
1392                .and_then(|value| value.get("progress_guard_warning"))
1393                .and_then(Value::as_str)
1394                .is_some_and(|warning| warning.contains("same investigation target"))
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn three_zero_evidence_searches_warn_even_when_queries_differ() {
1400        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1401        let hook = ProgressGuardHook { state };
1402        let context = ToolContext::new(SessionId::new());
1403        let mut last = result();
1404
1405        for pattern in ["history", "ground", "session"] {
1406            last = result_value(json!({ "ok": true, "count": 0, "matches": [] }));
1407            hook.after_exec(
1408                &call("grep_files", json!({ "pattern": pattern })),
1409                &tool_def("grep_files"),
1410                &mut last,
1411                &context,
1412            )
1413            .await;
1414        }
1415
1416        assert!(
1417            last.result
1418                .as_ref()
1419                .and_then(|value| value.get("progress_guard_warning"))
1420                .and_then(Value::as_str)
1421                .is_some_and(|warning| warning.contains("zero matches"))
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn positive_search_evidence_resets_zero_result_streak() {
1427        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1428        let hook = ProgressGuardHook { state };
1429        let context = ToolContext::new(SessionId::new());
1430
1431        for count in [0, 0, 1, 0, 0] {
1432            let mut out = result_value(json!({ "ok": true, "count": count }));
1433            hook.after_exec(
1434                &call("repo_map", json!({ "query": format!("query-{count}") })),
1435                &tool_def("repo_map"),
1436                &mut out,
1437                &context,
1438            )
1439            .await;
1440            assert!(
1441                out.result
1442                    .as_ref()
1443                    .and_then(|value| value.get("progress_guard_warning"))
1444                    .is_none(),
1445                "a positive result should break the zero-evidence streak"
1446            );
1447        }
1448    }
1449
1450    #[tokio::test]
1451    async fn repeated_truncated_exploration_warns_to_narrow_scope() {
1452        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1453        let hook = ProgressGuardHook { state };
1454        let context = ToolContext::new(SessionId::new());
1455        let mut last = result();
1456
1457        for query in ["runtime", "capability"] {
1458            last = result_value(json!({ "ok": true, "count": 50, "truncated": true }));
1459            hook.after_exec(
1460                &call("repo_map", json!({ "query": query })),
1461                &tool_def("repo_map"),
1462                &mut last,
1463                &context,
1464            )
1465            .await;
1466        }
1467
1468        assert!(
1469            last.result
1470                .as_ref()
1471                .and_then(|value| value.get("progress_guard_warning"))
1472                .and_then(Value::as_str)
1473                .is_some_and(|warning| warning.contains("truncated"))
1474        );
1475    }
1476
1477    #[tokio::test]
1478    async fn original_session_pattern_gets_interrupted_before_runaway_reads() {
1479        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1480        let hook = ProgressGuardHook { state };
1481        let context = ToolContext::new(SessionId::new());
1482        let mut warnings = Vec::new();
1483
1484        let observe = |tool_call: ToolCall| {
1485            let hook = &hook;
1486            let context = &context;
1487            async move {
1488                let mut out = result();
1489                hook.after_exec(&tool_call, &tool_def(&tool_call.name), &mut out, context)
1490                    .await;
1491                out.result
1492                    .as_ref()
1493                    .and_then(|value| value.get("progress_guard_warning"))
1494                    .and_then(Value::as_str)
1495                    .map(str::to_string)
1496            }
1497        };
1498
1499        // Same shape as the failed investigation: broad searches, then many
1500        // repeated reads of the callback-adjacent files, with no edit/test.
1501        let broad_searches = [
1502            "scheduled|schedule|signal_on_completion|callback|background",
1503            "spawn_background|signal_on_completion|scheduled_at",
1504            "cron|completion|task|background_run|Background|Task",
1505            "drain_finished_for_wake|wake_prompt|BackgroundRegistry",
1506            "SessionScheduleStore|schedule_store|create_schedule",
1507            "maybe_wake_for_background|TaskRegistryEvent",
1508        ];
1509        for pattern in broad_searches {
1510            if let Some(warning) = observe(call(
1511                "grep_files",
1512                json!({ "pattern": pattern, "path_pattern": "src/**/*.rs" }),
1513            ))
1514            .await
1515            {
1516                warnings.push(warning);
1517            }
1518        }
1519        for offset in [180, 388, 633, 940, 1370, 1540, 180, 388, 633] {
1520            if let Some(warning) = observe(call(
1521                "read_file",
1522                json!({ "path": "/repo/src/capabilities/background.rs", "offset": offset }),
1523            ))
1524            .await
1525            {
1526                warnings.push(warning);
1527            }
1528        }
1529        for offset in [1000, 1020, 1010, 1028, 1000, 1020, 1010, 1028, 1000] {
1530            if let Some(warning) = observe(call(
1531                "read_file",
1532                json!({ "path": "/repo/src/app/mod.rs", "offset": offset }),
1533            ))
1534            .await
1535            {
1536                warnings.push(warning);
1537            }
1538        }
1539        assert!(
1540            warnings
1541                .iter()
1542                .any(|warning| warning.contains("investigation tools")),
1543            "first threshold should warn before the session keeps circling: {warnings:?}"
1544        );
1545
1546        for i in 0..24 {
1547            if let Some(warning) = observe(call(
1548                "read_file",
1549                json!({ "path": "/repo/src/runtime.rs", "offset": 2100 + i }),
1550            ))
1551            .await
1552            {
1553                warnings.push(warning);
1554            }
1555        }
1556        assert!(
1557            warnings
1558                .iter()
1559                .any(|warning| warning.contains("checkpoint required")),
1560            "checkpoint escalation should trigger by 48 read/search calls: {warnings:?}"
1561        );
1562
1563        for _ in 0..REPEATED_EXPLORATION_THRESHOLD {
1564            if let Some(warning) = observe(call(
1565                "read_file",
1566                json!({ "path": "/repo/src/app/mod.rs", "offset": 1000 }),
1567            ))
1568            .await
1569            {
1570                warnings.push(warning);
1571            }
1572        }
1573        assert!(
1574            warnings
1575                .iter()
1576                .any(|warning| warning.contains("same investigation target")),
1577            "semantic repetition should catch rereading the same range: {warnings:?}"
1578        );
1579    }
1580
1581    #[tokio::test]
1582    async fn repeated_ci_polling_warns_toward_spawn_background() {
1583        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1584        let hook = ProgressGuardHook { state };
1585        let context = ToolContext::new(SessionId::new());
1586        let mut last = result();
1587
1588        // The classic poll loop: delay-then-check, over and over. Different
1589        // probe commands still count — polling rarely repeats verbatim.
1590        let polls = [
1591            "gh pr checks 42",
1592            "sleep 30 && gh pr checks 42",
1593            "gh run list --limit 1",
1594            "gh pr view 42",
1595        ];
1596        for command in polls {
1597            last = result();
1598            hook.after_exec(
1599                &call("bash", json!({ "commands": command })),
1600                &tool_def("bash"),
1601                &mut last,
1602                &context,
1603            )
1604            .await;
1605        }
1606
1607        assert!(
1608            last.result
1609                .as_ref()
1610                .and_then(|value| value.get("progress_guard_warning"))
1611                .and_then(Value::as_str)
1612                .is_some_and(|warning| warning.contains("spawn_background"))
1613        );
1614    }
1615
1616    #[tokio::test]
1617    async fn semantic_polling_cycle_warns_across_heterogeneous_tools() {
1618        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1619        let hook = ProgressGuardHook { state };
1620        let context = ToolContext::new(SessionId::new());
1621        let cycle = [
1622            call("bash", json!({ "commands": "gh pr checks 42" })),
1623            call("read_file", json!({ "path": "/tmp/synthetic-ci-note" })),
1624            call("get_task", json!({ "task_id": "task_ci" })),
1625            call("bash", json!({ "commands": "gh run view 123" })),
1626        ];
1627        let mut warnings = Vec::new();
1628
1629        for tool_call in cycle.into_iter().cycle().take(8) {
1630            let mut output = result();
1631            hook.after_exec(
1632                &tool_call,
1633                &tool_def(&tool_call.name),
1634                &mut output,
1635                &context,
1636            )
1637            .await;
1638            if let Some(warning) = output
1639                .result
1640                .as_ref()
1641                .and_then(|value| value.get("progress_guard_warning"))
1642                .and_then(Value::as_str)
1643            {
1644                warnings.push(warning.to_string());
1645            }
1646        }
1647
1648        assert!(
1649            warnings
1650                .iter()
1651                .any(|warning| warning.contains("spawn_background")),
1652            "a semantic polling cycle must be caught even when unrelated reads and task probes separate external checks: {warnings:?}"
1653        );
1654    }
1655
1656    #[tokio::test]
1657    async fn one_off_task_and_ci_status_checks_do_not_warn() {
1658        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1659        let hook = ProgressGuardHook { state };
1660        let context = ToolContext::new(SessionId::new());
1661
1662        for tool_call in [
1663            call("list_tasks", json!({})),
1664            call("get_task", json!({ "task_id": "task_once" })),
1665            call("bash", json!({ "commands": "gh pr checks 42" })),
1666        ] {
1667            let mut output = result();
1668            hook.after_exec(
1669                &tool_call,
1670                &tool_def(&tool_call.name),
1671                &mut output,
1672                &context,
1673            )
1674            .await;
1675            assert!(
1676                output
1677                    .result
1678                    .as_ref()
1679                    .and_then(|value| value.get("progress_guard_warning"))
1680                    .is_none(),
1681                "a one-off status check is legitimate"
1682            );
1683        }
1684    }
1685
1686    #[tokio::test]
1687    async fn interleaved_work_resets_waiting_window() {
1688        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1689        let hook = ProgressGuardHook { state };
1690        let context = ToolContext::new(SessionId::new());
1691
1692        // Check CI, fix something, check again — legitimate, because real
1693        // progress clears the semantic window.
1694        for _ in 0..(WAITING_WINDOW_THRESHOLD * 2) {
1695            let mut check = result();
1696            hook.after_exec(
1697                &call("bash", json!({ "commands": "gh pr checks 42" })),
1698                &tool_def("bash"),
1699                &mut check,
1700                &context,
1701            )
1702            .await;
1703            assert!(
1704                check
1705                    .result
1706                    .as_ref()
1707                    .and_then(|value| value.get("progress_guard_warning"))
1708                    .is_none()
1709            );
1710            hook.after_exec(
1711                &call("edit_file", json!({ "path": "/src/lib.rs" })),
1712                &tool_def("edit_file"),
1713                &mut result(),
1714                &context,
1715            )
1716            .await;
1717        }
1718    }
1719
1720    #[tokio::test]
1721    async fn repeated_git_status_warns() {
1722        let state = Arc::new(Mutex::new(ProgressGuardState::default()));
1723        let hook = ProgressGuardHook { state };
1724        let context = ToolContext::new(SessionId::new());
1725        let mut last = result();
1726
1727        for _ in 0..REPEATED_STATUS_THRESHOLD {
1728            last = result();
1729            hook.after_exec(
1730                &call("bash", json!({ "commands": "git status --short" })),
1731                &tool_def("bash"),
1732                &mut last,
1733                &context,
1734            )
1735            .await;
1736        }
1737
1738        assert!(
1739            last.result
1740                .as_ref()
1741                .and_then(|value| value.get("progress_guard_warning"))
1742                .and_then(Value::as_str)
1743                .is_some_and(|warning| warning.contains("repeated git status"))
1744        );
1745    }
1746}