use super::config::*;
use crate::{
core::{
self,
extensions::{
self, BeforeToolCallOutcome, BoundaryOutcome, Extension, ExtensionCommand,
ExtensionHook, ExtensionMode, ExtensionSetting, ExtensionTool, ExtensionUiRequest,
SubcommandDef, UiNotifyLevel,
},
provider::AgentMessage,
session_manager::Session,
tools::ToolError,
},
error::Result,
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_LOOP_DETECT, command_arg},
modes::interactive::{app::App, handlers::register_slash_command},
utils::time::now_iso,
};
use serde_json::{Value, json};
use std::{
collections::{HashMap, HashSet},
fs,
io::Write as _,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{
Arc, Mutex, MutexGuard, OnceLock,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use strum_macros::IntoStaticStr;
const PARA_MIN_LEN: usize = 40;
const FINGERPRINT_LEN: usize = 60;
const MIN_WORDS_FOR_SIMILARITY: usize = 5;
const SUBCOMMANDS: &[SubcommandDef] = &[SubcommandDef {
name: "reset",
description: "Clear detection state (history and counters)",
}];
static HOOK_WARNED: AtomicBool = AtomicBool::new(false);
static LOG_WARNED: AtomicBool = AtomicBool::new(false);
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static LOOP_DETECT_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_LOOP_DETECT,
make: || -> Arc<dyn Extension> { Arc::new(LoopDetect) },
};
fn lock_state() -> MutexGuard<'static, State> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
fn reset_all() {
lock_state().reset();
}
pub struct LoopDetect;
impl Extension for LoopDetect {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Detect thinking/output/tool-call loops and terminate the run as soon as one is found."
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: CMD.to_string(),
description: "Open the loop-detect settings panel; /loop-detect reset to clear state"
.to_string(),
busy_safe: true, subcommands: SUBCOMMANDS.to_vec(),
}]
}
fn settings(&self) -> Vec<ExtensionSetting> {
let st = lock_state();
panel_settings(&st.cfg)
}
fn apply_setting(&self, key: &str, value: &str) -> std::result::Result<(), String> {
{
let mut st = lock_state();
apply_panel_choice(&mut st.cfg, key, value)?;
}
let path = config_path();
let cfg = lock_state().cfg.clone();
write_config(&path, &cfg);
Ok(())
}
fn on_registered(&self) {
register_slash_command(EXT, CMD, command_loop_detect);
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
reset_all();
}
}
fn hooks(&self) -> Vec<ExtensionHook> {
interest_for(&lock_state())
}
fn on_boundary(&self, event: &Value) -> Option<BoundaryOutcome> {
if event.get("type").and_then(|v| v.as_str()) != Some("turn_end") {
return None;
}
let mut st = lock_state();
if !st.terminate_after_turn {
return None;
}
st.terminate_after_turn = false;
Some(BoundaryOutcome::end())
}
fn on_agent_event(&self, event: &Value) {
match event.get("type").and_then(|v| v.as_str()) {
Some("agent_start") => on_agent_start(),
Some("turn_start") => on_turn_start(),
Some("message_start") => on_message_start(event),
Some("message_update") => on_message_update(event),
Some("message_end") => on_message_end(event),
Some("agent_end") => on_agent_end(),
Some("agent_settled") => on_agent_settled(),
_ => {}
}
}
fn on_session_start(&self, cwd: &str, _messages: &[AgentMessage], session: Option<&Session>) {
{
let mut st = lock_state();
st.cwd = cwd.to_string();
if let Some(s) = session {
st.session_id = s.session_id.clone();
st.session_file = s
.session_file
.as_ref()
.map(|p| p.to_string_lossy().to_string());
}
}
reset_all();
}
fn on_session_switched(&self, session_path: Option<&str>, _messages: &[AgentMessage]) {
{
let mut st = lock_state();
st.session_file = session_path
.filter(|p| !p.is_empty())
.map(|p| p.to_string());
if let Some(path) = session_path.filter(|p| !p.is_empty())
&& let Ok(s) = Session::open(path)
{
st.session_id = s.session_id.clone();
st.cwd = s.cwd.clone();
}
}
reset_all();
}
fn transform_context(&self, _messages: &mut Vec<AgentMessage>) -> Result<()> {
let mut st = lock_state();
if !st.run_active {
begin_run(&mut st);
st.turn_index += 1;
}
Ok(())
}
fn before_tool_call_ext(
&self,
name: &str,
args: &Value,
_assistant_message: Option<&AgentMessage>,
_context: &[AgentMessage],
_all_args: &Value,
) -> std::result::Result<BeforeToolCallOutcome, ToolError> {
let hit = check_tool_call(&mut lock_state(), name, args);
let Some(hit) = hit else {
return Ok(BeforeToolCallOutcome::allow());
};
emit_detection(
"tool_loop",
json!({ "toolName": name, "windowSize": hit.window_size }),
);
notify_text(&hit.reason, UiNotifyLevel::Warning);
request_termination();
Ok(BeforeToolCallOutcome {
block: true,
reason: Some(hit.reason),
terminate: false,
args: None,
})
}
}
fn interest_for(st: &State) -> Vec<ExtensionHook> {
if !any_detector_enabled(&st.cfg) {
return Vec::new();
}
let mut out = vec![ExtensionHook::TransformContext];
if st.run_active || st.terminate_after_turn {
out.push(ExtensionHook::Boundary);
}
if st.run_active {
out.push(ExtensionHook::AgentEvent);
out.push(ExtensionHook::BeforeToolCall);
}
out
}
fn max_window_for(window: usize) -> usize {
(window * 50).min(4000)
}
fn stream_stride(window: usize) -> usize {
if window == 0 {
50
} else {
(window / 2).clamp(10, 50)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
enum StreamName {
Thinking,
Output,
}
impl StreamName {
fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
enum LoopKind {
Character,
Semantic,
}
impl LoopKind {
fn as_str(self) -> &'static str {
self.into()
}
}
#[derive(Default)]
struct SemanticState {
counts: HashMap<String, usize>,
scanned: usize,
in_fence: bool,
}
impl SemanticState {
fn new() -> Self {
Self::default()
}
}
struct State {
cfg: Config,
stream_aborted: bool,
last_checked_len: usize,
last_checked_output_len: usize,
cur_thinking_index: Option<u64>,
cur_text_index: Option<u64>,
thinking_buf: String,
thinking_len: usize,
text_buf: String,
text_len: usize,
thinking_sem: SemanticState,
output_sem: SemanticState,
loop_stream: StreamName,
loop_kind: LoopKind,
tool_history: Vec<String>,
thinking_history: Vec<String>,
turn_index: u64,
run_active: bool,
terminate_after_turn: bool,
cwd: String,
session_id: String,
session_file: Option<String>,
model_id: Option<String>,
model_name: Option<String>,
model_provider: Option<String>,
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| {
Mutex::new(State {
cfg: load_config(),
stream_aborted: false,
last_checked_len: 0,
last_checked_output_len: 0,
cur_thinking_index: None,
cur_text_index: None,
thinking_buf: String::new(),
thinking_len: 0,
text_buf: String::new(),
text_len: 0,
thinking_sem: SemanticState::new(),
output_sem: SemanticState::new(),
loop_stream: StreamName::Thinking,
loop_kind: LoopKind::Character,
tool_history: Vec::new(),
thinking_history: Vec::new(),
turn_index: 0,
run_active: false,
terminate_after_turn: false,
cwd: std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
session_id: String::new(),
session_file: None,
model_id: None,
model_name: None,
model_provider: None,
})
})
}
impl State {
fn reset_stream_state(&mut self) {
self.last_checked_len = 0;
self.last_checked_output_len = 0;
self.cur_thinking_index = None;
self.cur_text_index = None;
self.thinking_buf.clear();
self.thinking_len = 0;
self.text_buf.clear();
self.text_len = 0;
self.thinking_sem = SemanticState::new();
self.output_sem = SemanticState::new();
}
fn reset(&mut self) {
self.stream_aborted = false;
self.reset_stream_state();
self.loop_stream = StreamName::Thinking;
self.loop_kind = LoopKind::Character;
self.tool_history.clear();
self.thinking_history.clear();
self.turn_index = 0;
self.run_active = false;
self.terminate_after_turn = false;
}
fn reset_stream_only(&mut self) {
self.stream_aborted = false;
self.reset_stream_state();
self.loop_stream = StreamName::Thinking;
self.loop_kind = LoopKind::Character;
}
fn feed_update(&mut self, event: &Value) -> Option<StreamHit> {
if self.stream_aborted {
return None;
}
let ev = event.get("assistantMessageEvent")?;
let et = ev.get("type").and_then(|v| v.as_str())?;
let idx = ev.get("contentIndex").and_then(|v| v.as_u64());
match et {
"thinking_start" => {
self.cur_thinking_index = idx;
self.thinking_buf.clear();
self.thinking_len = 0;
self.last_checked_len = 0;
self.thinking_sem = SemanticState::new();
}
"thinking_delta" => {
let delta = ev.get("delta").and_then(|v| v.as_str())?;
if self.cur_thinking_index != idx {
self.cur_thinking_index = idx;
self.thinking_buf.clear();
self.thinking_len = 0;
self.last_checked_len = 0;
self.thinking_sem = SemanticState::new();
}
self.thinking_buf.push_str(delta);
self.thinking_len += char_len(delta);
return self.check_thinking();
}
"text_start" => {
self.cur_text_index = idx;
self.text_buf.clear();
self.text_len = 0;
self.last_checked_output_len = 0;
self.output_sem = SemanticState::new();
}
"text_delta" => {
let delta = ev.get("delta").and_then(|v| v.as_str())?;
if self.cur_text_index != idx {
self.cur_text_index = idx;
self.text_buf.clear();
self.text_len = 0;
self.last_checked_output_len = 0;
self.output_sem = SemanticState::new();
}
self.text_buf.push_str(delta);
self.text_len += char_len(delta);
return self.check_output();
}
_ => {}
}
None
}
fn check_thinking(&mut self) -> Option<StreamHit> {
let window = self.cfg.int("thinkingWindow");
let threshold = self.cfg.int("semanticThreshold");
let (kind, _) = check_stream(
&self.thinking_buf,
self.thinking_len,
&mut self.last_checked_len,
&mut self.thinking_sem,
window,
threshold,
)?;
self.loop_stream = StreamName::Thinking;
self.loop_kind = kind;
self.arm_after_detection()
}
fn check_output(&mut self) -> Option<StreamHit> {
let window = self.cfg.int("outputWindow");
let threshold = self.cfg.int("semanticThreshold");
let (kind, _) = check_stream(
&self.text_buf,
self.text_len,
&mut self.last_checked_output_len,
&mut self.output_sem,
window,
threshold,
)?;
self.loop_stream = StreamName::Output;
self.loop_kind = kind;
self.arm_after_detection()
}
fn arm_after_detection(&mut self) -> Option<StreamHit> {
self.stream_aborted = true;
Some(StreamHit {
stream: self.loop_stream,
kind: self.loop_kind,
})
}
}
struct StreamHit {
stream: StreamName,
kind: LoopKind,
}
fn begin_run(st: &mut State) {
st.reset();
st.run_active = true;
st.terminate_after_turn = false;
}
fn request_termination() {
lock_state().terminate_after_turn = true;
extensions::request_ui(ExtensionUiRequest::AbortRun);
}
fn on_agent_start() {
begin_run(&mut lock_state());
}
fn on_agent_end() {
reset_all();
}
fn on_agent_settled() {
reset_all();
}
fn on_turn_start() {
let mut st = lock_state();
st.turn_index += 1;
st.reset_stream_only();
}
fn on_message_start(event: &Value) {
let role = event
.get("message")
.and_then(|m| m.get("role"))
.and_then(|r| r.as_str())
.unwrap_or("");
if role != "assistant" {
return;
}
let mut st = lock_state();
st.reset_stream_only();
}
fn on_message_update(event: &Value) {
let hit = {
let mut st = lock_state();
st.feed_update(event)
};
if let Some(hit) = hit {
handle_stream_hit(hit);
}
}
fn handle_stream_hit(hit: StreamHit) {
let event_name = match (hit.stream, hit.kind) {
(StreamName::Thinking, LoopKind::Character) => "thinking_loop",
(StreamName::Thinking, LoopKind::Semantic) => "semantic_loop",
(StreamName::Output, LoopKind::Character) => "output_loop",
(StreamName::Output, LoopKind::Semantic) => "output_semantic_loop",
};
emit_detection(
event_name,
json!({ "stream": hit.stream.as_str(), "kind": hit.kind.as_str() }),
);
let reason = reason_stream(&lock_state().cfg, hit.stream, hit.kind);
notify_text(&reason, UiNotifyLevel::Warning);
request_termination();
}
fn on_message_end(event: &Value) {
let Some(message) = event.get("message") else {
return;
};
if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
return;
}
let thinking = extract_last_thinking(message);
if lock_state().stream_aborted {
let mut st = lock_state();
st.stream_aborted = false;
st.reset_stream_state();
return;
}
lock_state().reset_stream_state();
let stagnation = {
let mut st = lock_state();
let window = st.cfg.int("stagnationWindow");
if window == 0 {
None
} else if let Some(t) = thinking.filter(|t| !t.is_empty()) {
st.thinking_history.push(t);
while st.thinking_history.len() > window {
st.thinking_history.remove(0);
}
if st.thinking_history.len() >= window
&& st.thinking_history.windows(2).all(|w| {
similarity(&w[0], &w[1]).is_some_and(|s| s >= st.cfg.num("stagnationThreshold"))
})
{
let threshold = st.cfg.num("stagnationThreshold");
st.thinking_history.clear();
let reason = reason_stagnation(window, threshold);
Some((window, threshold, reason))
} else {
None
}
} else {
None
}
};
if let Some((window, threshold, reason)) = stagnation {
emit_detection(
"stagnation",
json!({ "window": window, "threshold": threshold }),
);
notify_text(&reason, UiNotifyLevel::Warning);
request_termination();
}
}
struct ToolHit {
window_size: usize,
reason: String,
}
fn check_tool_call(st: &mut State, name: &str, args: &Value) -> Option<ToolHit> {
if st.cfg.int("toolLoop") == 0 {
return None;
}
let hash = hash_tool_call(name, args);
if !is_exempt_tool(name, &st.cfg.text("toolLoopExempt")) {
let mut probe = st.tool_history.clone();
probe.push(hash.clone());
let window_size = detect_sequence_repeat(&probe);
if window_size > 0 {
return Some(ToolHit {
window_size,
reason: reason_tool_loop(name, window_size),
});
}
}
st.tool_history.push(hash);
None
}
fn build_payload(st: &State, event: &str, details: Value) -> Value {
let model = match (&st.model_id, &st.model_name, &st.model_provider) {
(Some(id), name, provider) => json!({ "id": id, "name": name, "provider": provider }),
_ => Value::Null,
};
json!({
"type": "loop-detect:detection",
"event": event,
"timestamp": now_iso(),
"model": model,
"sessionId": st.session_id,
"sessionFile": st.session_file,
"cwd": st.cwd,
"turnIndex": st.turn_index,
"details": details,
})
}
fn emit_detection(event: &str, details: Value) {
let (payload, hook_cmd, hook_log, timeout_ms) = {
let st = lock_state();
(
build_payload(&st, event, details),
st.cfg.text("hookCmd"),
st.cfg.text("hookLog"),
st.cfg.int("hookTimeoutMs").max(1) as u64,
)
};
let line = serde_json::to_string(&payload).unwrap_or_default();
core::extensions::dispatch_agent_event(&payload);
if !hook_cmd.trim().is_empty() {
spawn_hook(&hook_cmd, &line, timeout_ms);
}
if !hook_log.trim().is_empty() {
append_log(&hook_log, &line);
}
}
fn spawn_hook(cmd: &str, payload: &str, timeout_ms: u64) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
return;
}
let mut command = Command::new(parts[0]);
command
.args(&parts[1..])
.arg(payload)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(mut child) => {
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
_ = child.kill();
_ = child.wait();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
});
}
Err(err) => {
if !HOOK_WARNED.swap(true, Ordering::Relaxed) {
notify_text(
&format!("loop-detect: hookCmd failed — {err}"),
UiNotifyLevel::Warning,
);
}
}
}
}
fn append_log(log: &str, line: &str) {
let path = if Path::new(log).is_absolute() {
PathBuf::from(log)
} else {
Path::new(&lock_state().cwd).join(log)
};
let result = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| writeln!(f, "{line}"));
if result.is_err() && !LOG_WARNED.swap(true, Ordering::Relaxed) {
notify_text(
&format!("loop-detect: cannot write hookLog — {}", path.display()),
UiNotifyLevel::Warning,
);
}
}
fn notify_text(text: &str, level: UiNotifyLevel) {
extensions::request_ui(ExtensionUiRequest::Notify {
text: text.to_string(),
level,
});
}
fn command_loop_detect(app: &mut App, raw: &str) -> bool {
let trimmed = command_arg(raw).trim();
if trimmed.is_empty() {
if app.ext_settings.is_open_for(EXT) {
app.ext_settings.close();
} else {
app.ext_settings.open(EXT);
}
app.dirty = true;
return false;
}
if trimmed == "reset" {
reset_all();
notify_text("loop-detect: state reset", UiNotifyLevel::Info);
return false;
}
let st = lock_state();
let cfg = &st.cfg;
let mut lines = vec![
"loop-detect status".to_string(),
format!(" run active: {}", st.run_active),
format!(" stream aborted: {}", st.stream_aborted),
format!(" tool history: {} calls", st.tool_history.len()),
format!(
" stagnation history: {}/{} turns",
st.thinking_history.len(),
cfg.int("stagnationWindow")
),
String::new(),
" config (edit loop-detect.json or use the /loop-detect panel):".to_string(),
];
for (key, _) in NUMERIC_DEFAULTS {
lines.push(format!(" {key}={}", cfg.num(key)));
}
for (key, _) in STRING_DEFAULTS {
lines.push(format!(" {key}=\"{}\"", cfg.text(key)));
}
notify_text(&lines.join("\n"), UiNotifyLevel::Info);
false
}
fn char_len(s: &str) -> usize {
s.chars().count()
}
fn reason_stream(cfg: &Config, stream: StreamName, kind: LoopKind) -> String {
let (what, detail) = match kind {
LoopKind::Character => {
let window = match stream {
StreamName::Thinking => cfg.int("thinkingWindow"),
StreamName::Output => cfg.int("outputWindow"),
};
("char loop", format!("repeated {window}-char block"))
}
LoopKind::Semantic => (
"semantic loop",
format!("{} repeated paragraphs", cfg.int("semanticThreshold")),
),
};
format!(
"⛔ loop-detect: terminated — {} {what} ({detail})",
stream.as_str()
)
}
fn reason_stagnation(window: usize, threshold: f64) -> String {
let percent = (threshold * 100.0).round() as i64;
format!(
"⛔ loop-detect: terminated — reasoning stagnation across last {window} turns ({percent}%+ similar)"
)
}
fn reason_tool_loop(name: &str, window_size: usize) -> String {
format!(
"⛔ loop-detect: terminated — tool call loop: same {window_size}-call sequence repeating ({name})"
)
}
fn jaccard(a: &str, b: &str) -> f64 {
let set_a: HashSet<String> = a
.to_lowercase()
.split_whitespace()
.map(str::to_string)
.collect();
let set_b: HashSet<String> = b
.to_lowercase()
.split_whitespace()
.map(str::to_string)
.collect();
let inter = set_a.intersection(&set_b).count();
let union = set_a.len() + set_b.len() - inter;
if union == 0 {
1.0
} else {
inter as f64 / union as f64
}
}
fn similarity(a: &str, b: &str) -> Option<f64> {
let words = |s: &str| s.split_whitespace().count();
if words(a) < MIN_WORDS_FOR_SIMILARITY || words(b) < MIN_WORDS_FOR_SIMILARITY {
return None;
}
Some(jaccard(a, b))
}
fn is_exempt_tool(name: &str, exempt: &str) -> bool {
if exempt.trim().is_empty() {
return false;
}
exempt
.split(',')
.any(|t| t.trim().eq_ignore_ascii_case(name))
}
fn hash_tool_call(name: &str, args: &Value) -> String {
format!("{name}:{}", serde_json::to_string(args).unwrap_or_default())
}
fn extract_last_thinking(message: &Value) -> Option<String> {
let content = message.get("content")?.as_array()?;
for block in content.iter().rev() {
if block.get("type").and_then(|v| v.as_str()) == Some("thinking")
&& let Some(t) = block.get("thinking").and_then(|v| v.as_str())
{
return Some(t.to_string());
}
}
None
}
fn detect_sequence_repeat(history: &[String]) -> usize {
let n = history.len();
for w in 1..=(n / 2) {
let tail = &history[n - w..];
let prev = &history[n - w * 2..n - w];
if prev.len() == w && tail.iter().zip(prev).all(|(a, b)| a == b) {
return w;
}
}
0
}
fn z_array(s: &[char]) -> Vec<usize> {
let n = s.len();
let mut z = vec![0usize; n];
if n == 0 {
return z;
}
z[0] = n;
let (mut l, mut r) = (0usize, 0usize);
for i in 1..n {
if i < r {
z[i] = (r - i).min(z[i - l]);
}
while i + z[i] < n && s[z[i]] == s[i + z[i]] {
z[i] += 1;
}
if i + z[i] > r {
l = i;
r = i + z[i];
}
}
z
}
fn detect_repeating_suffix(
text: &str,
n: usize,
min_window: usize,
max_window: usize,
) -> Option<String> {
let max_w = max_window.min(n / 2);
if min_window == 0 || max_w < min_window {
return None;
}
let tail: Vec<char> = text.chars().rev().take(2 * max_w).collect();
let z = z_array(&tail);
for (w, &zw) in z.iter().enumerate().take(max_w + 1).skip(min_window) {
if zw >= w {
return Some(text.chars().take(n - w).collect());
}
}
None
}
fn check_stream(
buf: &str,
len: usize,
last_checked_len: &mut usize,
sem: &mut SemanticState,
window: usize,
semantic_threshold: usize,
) -> Option<(LoopKind, String)> {
if window == 0 && semantic_threshold == 0 {
return None;
}
let stride = stream_stride(window);
if len < last_checked_len.saturating_add(stride) {
return None;
}
*last_checked_len = len;
if window > 0
&& let Some(clean) = detect_repeating_suffix(buf, len, window, max_window_for(window))
{
return Some((LoopKind::Character, clean));
}
if semantic_threshold > 0
&& let Some(clean) =
detect_semantic_loop(buf, sem, PARA_MIN_LEN, FINGERPRINT_LEN, semantic_threshold)
{
return Some((LoopKind::Semantic, clean));
}
None
}
fn detect_semantic_loop(
text: &str,
state: &mut SemanticState,
para_min_len: usize,
fingerprint_len: usize,
threshold: usize,
) -> Option<String> {
if threshold == 0 {
return None;
}
let mut pos = state.scanned;
let mut in_fence = state.in_fence;
loop {
let delim = find_blank_line(text, pos);
let end = delim.map(|(start, _)| start).unwrap_or(text.len());
let para = &text[pos..end];
let fence_marks = para.matches("```").count();
if !in_fence && fence_marks == 0 {
let trimmed = para.trim();
if char_len(trimmed) >= para_min_len {
let fingerprint_text = strip_leading_counter(trimmed);
let key: String = fingerprint_text.chars().take(fingerprint_len).collect();
let count = state.counts.get(&key).copied().unwrap_or(0) + 1;
if count >= threshold {
return Some(text[..pos].to_string());
}
if delim.is_some() {
state.counts.insert(key, count);
}
}
}
let (_, delim_end) = delim?;
if fence_marks % 2 == 1 {
in_fence = !in_fence;
}
pos = delim_end;
state.scanned = pos;
state.in_fence = in_fence;
}
}
fn find_blank_line(text: &str, from: usize) -> Option<(usize, usize)> {
let b = text.as_bytes();
let mut i = from;
while i < b.len() {
if b[i] == b'\n' {
let start = if i > from && b[i - 1] == b'\r' {
i - 1
} else {
i
};
let mut j = skip_hspace(b, i + 1);
if j < b.len() && b[j] == b'\r' && j + 1 < b.len() && b[j + 1] == b'\n' {
j += 1;
}
if j < b.len() && b[j] == b'\n' {
let mut end = j + 1;
loop {
let mut k = skip_hspace(b, end);
if k < b.len() && b[k] == b'\r' {
k += 1;
}
if k < b.len() && b[k] == b'\n' {
end = k + 1;
} else {
break;
}
}
return Some((start, end));
}
}
i += 1;
}
None
}
fn skip_hspace(b: &[u8], mut i: usize) -> usize {
while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
i
}
fn strip_leading_counter(s: &str) -> String {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0 && i < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
let mut j = i + 1;
if j < bytes.len() && (bytes[j] as char).is_whitespace() {
while j < bytes.len() && (bytes[j] as char).is_whitespace() {
j += 1;
}
let mut out = String::from("#");
out.push(bytes[i] as char);
out.push(' ');
out.push_str(&s[j..]);
return out;
}
}
s.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::provider::ContentBlock;
use crate::utils::time::civil_from_days;
fn fresh() {
reset_all();
lock_state().cfg = merge_config(None);
}
fn cfg_default() -> Config {
merge_config(None)
}
#[test]
fn jaccard_bounds() {
assert_eq!(jaccard("a b c", "a b c"), 1.0);
assert_eq!(jaccard("a b", "c d"), 0.0);
assert_eq!(jaccard("", ""), 1.0);
let mid = jaccard("a b c d", "a b x y");
assert!((mid - 0.333).abs() < 0.01, "mid={mid}");
}
#[test]
fn repeating_suffix_finds_adjacent_copy() {
let unit: String = "0123456789".repeat(8);
let text = format!("{unit}{unit}");
let n = char_len(&text);
let clean = detect_repeating_suffix(&text, n, 80, 4000).expect("hit");
assert_eq!(char_len(&clean), 80);
assert!(detect_repeating_suffix("hello world", 11, 80, 4000).is_none());
assert!(detect_repeating_suffix(&unit, 80, 80, 4000).is_none());
}
#[test]
fn semantic_loop_counts_paragraph_repeats() {
let para = "This is a reasonably long paragraph of reasoning text that repeats.";
let text = format!("{para}\n\n{para}\n\n{para}\n\n");
let mut state = SemanticState::new();
let hit = detect_semantic_loop(&text, &mut state, PARA_MIN_LEN, FINGERPRINT_LEN, 3);
assert!(hit.is_some(), "third repeat should fire");
}
#[test]
fn semantic_loop_normalizes_list_counters() {
let a = "23. Step through the repository and enumerate every module carefully.";
let b = "28. Step through the repository and enumerate every module carefully.";
let c = "33. Step through the repository and enumerate every module carefully.";
let text = format!("{a}\n\n{b}\n\n{c}\n\n");
let mut state = SemanticState::new();
assert!(
detect_semantic_loop(&text, &mut state, 40, 60, 3).is_some(),
"changing ordinals must not disguise repeats"
);
}
#[test]
fn semantic_loop_skips_code_fences() {
let para = "A long paragraph that lives inside a fenced code block and repeats.";
let text = format!("```\n{para}\n{para}\n{para}\n```\n\nend");
let mut state = SemanticState::new();
assert!(detect_semantic_loop(&text, &mut state, 40, 60, 3).is_none());
}
#[test]
fn sequence_repeat_detects_any_period() {
let h = |items: &[&str]| items.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(detect_sequence_repeat(&h(&["a", "a"])), 1);
assert_eq!(detect_sequence_repeat(&h(&["a", "b", "a", "b"])), 2);
assert_eq!(detect_sequence_repeat(&h(&["a", "b", "c"])), 0);
assert_eq!(
detect_sequence_repeat(&h(&["a", "b", "c", "a", "b", "c"])),
3
);
}
#[test]
fn config_validation_rejects_bad_values() {
let file = json!({
"thinkingWindow": -5,
"stagnationThreshold": 1.5,
"toolLoop": 9,
"hookCmd": "node x.mjs",
"toolLoopExempt": 42
});
let cfg = merge_config(Some(&file));
assert_eq!(cfg.int("thinkingWindow"), 80, "negative falls back");
assert_eq!(cfg.num("stagnationThreshold"), 0.85, ">1 falls back");
assert_eq!(cfg.int("toolLoop"), 1, ">1 falls back");
assert_eq!(cfg.text("hookCmd"), "node x.mjs");
assert_eq!(cfg.text("toolLoopExempt"), "", "wrong type falls back");
}
#[test]
fn command_parses_subcommand_after_command_name() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let mut app = App::new();
lock_state().tool_history.push("x".to_string());
command_loop_detect(&mut app, "loop-detect reset");
assert!(
lock_state().tool_history.is_empty(),
"`loop-detect reset` 应复位状态"
);
fresh();
}
#[test]
fn loop_detect_subcommands_match_parser() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let declared = LoopDetect
.commands()
.into_iter()
.find(|c| c.name == CMD)
.expect("loop-detect 命令应注册")
.subcommands;
let names: Vec<&str> = declared.iter().map(|s| s.name).collect();
assert_eq!(names, vec!["reset"], "只声明解析器实现的子命令");
for sub in &declared {
let mut app = App::new();
lock_state().tool_history.push("x".to_string());
command_loop_detect(&mut app, &format!("{} {}", CMD, sub.name));
assert!(
lock_state().tool_history.is_empty(),
"{} 应复位状态",
sub.name
);
}
fresh();
}
#[test]
fn tool_exempt_list_is_case_insensitive() {
assert!(is_exempt_tool("Bash", "bash,run_tests"));
assert!(!is_exempt_tool("read", "bash"));
assert!(!is_exempt_tool("read", " "));
}
#[test]
fn strip_leading_counter_normalizes() {
assert_eq!(strip_leading_counter("23. foo"), "#. foo");
assert_eq!(strip_leading_counter("7) bar"), "#) bar");
assert_eq!(strip_leading_counter("plain"), "plain");
assert_eq!(
strip_leading_counter("23.foo"),
"23.foo",
"no whitespace → untouched"
);
}
#[test]
fn iso_timestamp_shape() {
let ts = now_iso();
assert!(ts.ends_with('Z') && ts.len() == 24, "ts={ts}");
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn tool_loop_detects_sequence_and_does_not_count_blocked() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let args = json!({"command": "ls"});
{
let mut st = lock_state();
assert!(check_tool_call(&mut st, "bash", &args).is_none());
assert_eq!(st.tool_history.len(), 1);
let hit = check_tool_call(&mut st, "bash", &args).expect("second identical call");
assert_eq!(hit.window_size, 1);
assert!(hit.reason.contains("tool call loop"));
assert_eq!(st.tool_history.len(), 1, "blocked call not recorded");
let other = json!({"command": "pwd"});
assert!(check_tool_call(&mut st, "bash", &other).is_none());
assert_eq!(st.tool_history.len(), 2);
}
fresh();
}
#[test]
fn tool_loop_off_disables_detector() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
{
let mut st = lock_state();
st.cfg.values.insert("toolLoop".into(), json!(0));
let args = json!({"command": "ls"});
assert!(check_tool_call(&mut st, "bash", &args).is_none());
assert!(check_tool_call(&mut st, "bash", &args).is_none());
assert!(st.tool_history.is_empty(), "关闭时不记录");
}
fresh();
}
#[test]
fn transform_context_is_probe_only() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let mut messages = vec![AgentMessage {
role: "assistant".into(),
thinking_level: None,
content: vec![ContentBlock::Thinking {
thinking: "loop reasoning".into(),
thinking_signature: Some("sig".into()),
redacted: None,
}],
tool_call_id: None,
tool_name: None,
is_error: false,
stop_reason: None,
error_message: None,
model: None,
provider: None,
api: None,
response_model: None,
response_id: None,
diagnostics: None,
usage: None,
deferred: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
duration_ms: None,
details: None,
citations: None,
entry_id: None,
}];
LoopDetect.transform_context(&mut messages).unwrap();
assert_eq!(messages.len(), 1);
match &messages[0].content[0] {
ContentBlock::Thinking {
thinking,
thinking_signature,
..
} => {
assert_eq!(thinking, "loop reasoning");
assert_eq!(thinking_signature.as_deref(), Some("sig"));
}
other => panic!("expected untouched thinking block, got {other:?}"),
}
let st = lock_state();
assert!(st.run_active, "transform_context 应完成上电");
assert_eq!(st.turn_index, 1, "首个 turn_start 被错过时应补齐计数");
drop(st);
fresh();
}
#[test]
fn detection_payload_shape() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let payload = {
let st = lock_state();
build_payload(
&st,
"tool_loop",
json!({"toolName": "bash", "windowSize": 2}),
)
};
assert_eq!(payload["type"], "loop-detect:detection");
assert_eq!(payload["event"], "tool_loop");
assert_eq!(payload["details"]["toolName"], "bash");
assert!(payload["timestamp"].as_str().unwrap().ends_with('Z'));
assert!(payload.get("sessionId").is_some());
assert!(payload.get("cwd").is_some());
fresh();
}
#[test]
fn stream_loop_queues_abort_and_notify_only() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
while extensions::take_pending_ui().is_some() {}
fresh();
{
let mut st = lock_state();
st.cfg.values.insert("thinkingWindow".into(), json!(20));
st.cfg.values.insert("semanticThreshold".into(), json!(0));
}
let unit = "abcdefghijklmnopqrst"; let text = format!("{unit}{unit}");
LoopDetect
.on_agent_event(&json!({"type": "message_start", "message": {"role": "assistant"}}));
LoopDetect
.on_agent_event(&json!({"type": "message_update", "assistantMessageEvent": {"type": "thinking_start", "contentIndex": 0}}));
let chars: Vec<char> = text.chars().collect();
for chunk in chars.chunks(5) {
let delta: String = chunk.iter().collect();
LoopDetect.on_agent_event(&json!({
"type": "message_update",
"assistantMessageEvent": {"type": "thinking_delta", "contentIndex": 0, "delta": delta}
}));
}
{
let st = lock_state();
assert!(st.stream_aborted, "detector should have fired");
assert_eq!(st.loop_stream, StreamName::Thinking);
assert_eq!(st.loop_kind, LoopKind::Character);
}
let mut saw_abort = false;
let mut saw_notify = false;
while let Some(req) = extensions::take_pending_ui() {
match req {
ExtensionUiRequest::AbortRun => saw_abort = true,
ExtensionUiRequest::Notify { text, .. } => {
saw_notify = true;
assert!(text.contains("terminated"), "理由应说明终止: {text}");
}
ExtensionUiRequest::Continuation { .. } => {
panic!("纯检测扩展不应再投递续跑消息")
}
_ => {}
}
}
assert!(saw_abort, "AbortRun must be requested");
assert!(saw_notify, "终止理由系统消息必须发送");
fresh();
while extensions::take_pending_ui().is_some() {}
}
#[test]
fn stagnation_terminates_run() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
while extensions::take_pending_ui().is_some() {}
fresh();
{
let mut st = lock_state();
st.cfg.values.insert("stagnationWindow".into(), json!(2));
st.cfg
.values
.insert("stagnationThreshold".into(), json!(0.5));
st.cfg.values.insert("thinkingWindow".into(), json!(0));
st.cfg.values.insert("semanticThreshold".into(), json!(0));
st.cfg.values.insert("toolLoop".into(), json!(0));
}
let thinking = "we should keep reading the same file and then continue with the task";
for _ in 0..2 {
LoopDetect.on_agent_event(&json!({
"type": "message_end",
"message": {"role": "assistant", "content": [{"type": "thinking", "thinking": thinking}]}
}));
}
let mut saw_abort = false;
let mut saw_notify = false;
while let Some(req) = extensions::take_pending_ui() {
match req {
ExtensionUiRequest::AbortRun => saw_abort = true,
ExtensionUiRequest::Notify { text, .. } => {
saw_notify = true;
assert!(text.contains("stagnation"), "理由应说明停滞: {text}");
}
ExtensionUiRequest::Continuation { .. } => panic!("不应投递续跑消息"),
_ => {}
}
}
assert!(saw_abort && saw_notify, "停滞应终止 run 并发系统消息");
fresh();
while extensions::take_pending_ui().is_some() {}
}
#[test]
fn detection_ends_run_at_turn_end_boundary() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
while extensions::take_pending_ui().is_some() {}
fresh();
{
let mut st = lock_state();
st.cfg.values.insert("stagnationWindow".into(), json!(2));
st.cfg
.values
.insert("stagnationThreshold".into(), json!(0.5));
st.cfg.values.insert("thinkingWindow".into(), json!(0));
st.cfg.values.insert("semanticThreshold".into(), json!(0));
st.cfg.values.insert("toolLoop".into(), json!(0));
}
assert!(
LoopDetect
.on_boundary(&json!({ "type": "turn_end", "outcome": "completed" }))
.is_none(),
"未命中时不应干预边界"
);
let thinking = "we should keep reading the same file and then continue with the task";
for _ in 0..2 {
LoopDetect.on_agent_event(&json!({
"type": "message_end",
"message": {"role": "assistant", "content": [{"type": "thinking", "thinking": thinking}]}
}));
}
let outcome = LoopDetect
.on_boundary(&json!({ "type": "turn_end", "outcome": "completed" }))
.expect("命中后 turn_end 边界应返回决策");
assert!(outcome.end && !outcome.r#continue, "应要求优雅结束 run");
assert!(
LoopDetect
.on_boundary(&json!({ "type": "turn_end", "outcome": "completed" }))
.is_none(),
"终止标志应一次性消费"
);
assert!(
LoopDetect
.on_boundary(&json!({ "type": "agent_before_settle", "outcome": "completed" }))
.is_none()
);
fresh();
while extensions::take_pending_ui().is_some() {}
}
#[test]
fn run_end_resets_all_detection_state() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let dirty = |st: &mut State| {
begin_run(st);
st.stream_aborted = true;
st.tool_history.push("bash:{}".to_string());
st.thinking_history.push("stale thinking".to_string());
st.turn_index = 3;
st.loop_stream = StreamName::Output;
st.loop_kind = LoopKind::Semantic;
};
{
let mut st = lock_state();
dirty(&mut st);
}
on_agent_end();
{
let st = lock_state();
assert!(!st.run_active, "agent_end 应断电");
assert!(!st.stream_aborted);
assert!(st.tool_history.is_empty());
assert!(st.thinking_history.is_empty());
assert_eq!(st.turn_index, 0);
assert_eq!(st.loop_stream, StreamName::Thinking);
assert_eq!(st.loop_kind, LoopKind::Character);
}
{
let mut st = lock_state();
dirty(&mut st);
}
on_agent_settled();
{
let st = lock_state();
assert!(!st.run_active);
assert!(st.tool_history.is_empty());
assert_eq!(st.turn_index, 0);
}
fresh();
}
#[test]
fn panel_choices_roundtrip() {
for spec in panel_settings(&cfg_default()) {
let mut cfg = cfg_default();
for choice in &spec.choices {
apply_panel_choice(&mut cfg, &spec.key, choice)
.unwrap_or_else(|e| panic!("apply {}/{choice}: {e}", spec.key));
assert_eq!(
&panel_value(&cfg, &spec.key),
choice,
"roundtrip key={} choice={}",
spec.key,
choice
);
}
assert!(
apply_panel_choice(&mut cfg, &spec.key, "nope").is_err(),
"{} 应拒绝非法档位",
spec.key
);
}
}
#[test]
fn settings_reflect_config_and_apply_persists() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let settings = LoopDetect.settings();
assert_eq!(settings.len(), panel_settings(&cfg_default()).len());
let thinking = settings.iter().find(|s| s.key == "streamThinking").unwrap();
assert_eq!(thinking.value, "normal");
assert!(thinking.choices.contains(&"tight".to_string()));
LoopDetect.apply_setting("streamThinking", "tight").unwrap();
assert_eq!(lock_state().cfg.int("thinkingWindow"), 40);
let path = crate::core::settings_manager::agent_dir()
.join("extensions")
.join(CONFIG_FILE);
assert_eq!(CONFIG_FILE, "loop-detect.json");
assert!(path.exists(), "面板变更应立即写盘: {}", path.display());
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("\"thinkingWindow\""));
assert!(
!text.contains("THINKING_WINDOW"),
"落盘键名必须全小写: {text}"
);
assert!(LoopDetect.apply_setting("nope", "x").is_err());
fresh();
}
#[test]
fn config_file_writes_lowercase_keys_only() {
let file = json!({
"thinkingWindow": 40,
"semanticThreshold": 2,
"hookCmd": "node hook.mjs",
});
let cfg = merge_config(Some(&file));
assert_eq!(cfg.int("thinkingWindow"), 40);
assert_eq!(cfg.num("semanticThreshold"), 2.0);
assert_eq!(cfg.text("hookCmd"), "node hook.mjs");
let path = std::env::temp_dir().join(format!(
"loop-detect-lowercase-{}-{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
write_config(&path, &cfg);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("\"thinkingWindow\""));
assert!(text.contains("\"hookCmd\""));
for key in default_keys() {
assert!(text.contains(&format!("\"{key}\"")), "缺键 {key}");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn config_cache_reflects_external_edits() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"thinkingWindow": 40}"#).unwrap();
assert_eq!(load_config().int("thinkingWindow"), 40);
std::fs::write(&path, r#"{"thinkingWindow": 160}"#).unwrap();
assert_eq!(load_config().int("thinkingWindow"), 160);
std::fs::remove_file(&path).unwrap();
assert_eq!(load_config().int("thinkingWindow"), 80);
}
#[test]
fn no_args_opens_panel() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let mut app = App::new();
command_loop_detect(&mut app, "loop-detect");
assert!(app.ext_settings.visible, "无参数应打开面板");
assert_eq!(app.ext_settings.ext, EXT);
command_loop_detect(&mut app, "loop-detect");
assert!(!app.ext_settings.is_open(), "再次运行应关闭面板");
fresh();
}
#[test]
fn similarity_gates_short_text() {
assert!(similarity("ok", "ok").is_none(), "短文本不可比");
let a = "we should read the file and then continue with the current task";
let b = "we should read the file and then continue with the current work";
let s = similarity(a, b).expect("足够长可比");
assert!(s > 0.7, "s={s}");
}
#[test]
fn reason_texts_name_the_detector() {
let cfg = cfg_default();
let s = reason_stream(&cfg, StreamName::Thinking, LoopKind::Character);
assert!(s.contains("thinking") && s.contains("80-char"), "{s}");
let s = reason_stream(&cfg, StreamName::Output, LoopKind::Semantic);
assert!(s.contains("output") && s.contains("3 repeated"), "{s}");
let s = reason_stagnation(4, 0.85);
assert!(s.contains("4 turns") && s.contains("85%"), "{s}");
let s = reason_tool_loop("bash", 2);
assert!(s.contains("2-call") && s.contains("bash"), "{s}");
}
#[test]
fn hooks_follow_run_state() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
fresh();
let ext = LoopDetect;
let hooks = ext.hooks();
assert!(hooks.contains(&ExtensionHook::TransformContext));
assert!(
!hooks.contains(&ExtensionHook::Boundary),
"空闲不订阅边界(run 结束后不再需要 turn_end 收尾)"
);
assert!(!hooks.contains(&ExtensionHook::AgentEvent));
assert!(!hooks.contains(&ExtensionHook::BeforeToolCall));
{
let mut st = lock_state();
begin_run(&mut st);
}
let hooks = ext.hooks();
assert!(hooks.contains(&ExtensionHook::AgentEvent));
assert!(hooks.contains(&ExtensionHook::BeforeToolCall));
assert!(
hooks.contains(&ExtensionHook::Boundary),
"run 内需 turn_end 收尾"
);
on_agent_end();
assert!(!ext.hooks().contains(&ExtensionHook::AgentEvent));
assert!(!ext.hooks().contains(&ExtensionHook::BeforeToolCall));
assert!(
!ext.hooks().contains(&ExtensionHook::Boundary),
"run 结束后不再订阅边界"
);
begin_run(&mut lock_state());
assert!(ext.hooks().contains(&ExtensionHook::AgentEvent));
on_agent_settled();
assert!(!ext.hooks().contains(&ExtensionHook::AgentEvent));
{
let mut st = lock_state();
for k in [
"thinkingWindow",
"outputWindow",
"semanticThreshold",
"stagnationWindow",
"toolLoop",
] {
st.cfg.values.insert(k.to_string(), json!(0));
}
}
assert!(ext.hooks().is_empty(), "无检测器:完全跳过");
fresh();
}
}