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