1use 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 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 self.recent_observations.clear();
214 self.observation_hashes.clear();
215
216 let Some(transition) = mutation_hash_transition(tool_call, result) else {
217 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 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 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 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 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 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
637fn 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 assert_eq!(
976 classify_bash_command("sleep 5 && cargo test --all-features"),
977 ToolClass::Validation("cargo test --all-features".to_string())
978 );
979 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 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 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 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}