mod command;
mod config;
mod run;
use crate::{
core::{
extensions::{
BeforeToolCallOutcome, BoundaryOutcome, Extension, ExtensionCommand, ExtensionHook,
ExtensionMode, ExtensionSetting, ExtensionTool, UiNotifyLevel,
},
provider::AgentMessage,
session_manager::Session,
tools::ToolError,
},
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_VET,
util::{notify_text, truncate_chars},
},
modes::interactive::handlers::register_slash_command,
utils::{glyphs::DEF_TRAILING_ELLIPSIS, paths::cwd, time::format_duration_ms},
};
use config::{Announce, Config, EXT};
use run::CheckResult;
use serde_json::Value;
use std::{
path::PathBuf,
sync::{
Arc, Mutex, MutexGuard,
atomic::{AtomicBool, Ordering},
},
time::Instant,
};
const WRITE_TOOLS: &[&str] = &["edit", "write", "bash", "powershell"];
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static VET_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_VET,
make: || -> Arc<dyn Extension> { Arc::new(Vet) },
};
static SESSION_CWD: Mutex<Option<PathBuf>> = Mutex::new(None);
static STATE: Mutex<State> = Mutex::new(State::new());
static RUNNING: AtomicBool = AtomicBool::new(false);
static WARNED: AtomicBool = AtomicBool::new(false);
const ADD_PROJECT: &str = "--project";
const ADD_OPTIONAL: &str = "--optional";
const DETECT_WRITE: &str = "--write";
struct State {
iteration: usize,
dirty: bool,
last_failure: Option<u64>,
last_verdict: Option<String>,
last_run_at: Option<Instant>,
runs: usize,
}
impl State {
const fn new() -> Self {
State {
iteration: 0,
dirty: false,
last_failure: None,
last_verdict: None,
last_run_at: None,
runs: 0,
}
}
}
fn state() -> MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
fn reset_turn_state() {
let mut st = state();
st.iteration = 0;
st.dirty = false;
st.last_failure = None;
}
fn remember_cwd(cwd: &str) {
*SESSION_CWD.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(cwd));
}
fn current_cwd() -> PathBuf {
if let Some(cwd) = SESSION_CWD
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
return cwd;
}
cwd()
}
fn is_write_tool(name: &str) -> bool {
WRITE_TOOLS.contains(&name)
}
struct RunGuard;
impl RunGuard {
fn acquire() -> Option<RunGuard> {
RUNNING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.ok()
.map(|_| RunGuard)
}
}
impl Drop for RunGuard {
fn drop(&mut self) {
RUNNING.store(false, Ordering::SeqCst);
}
}
fn check_names(cfg: &Config) -> String {
let names: Vec<String> = cfg
.checks
.iter()
.take(4)
.map(|c| truncate_chars(&c.name, 24))
.collect();
let extra = cfg.checks.len().saturating_sub(names.len());
let mut text = names.join(", ");
if extra > 0 {
text.push_str(&format!(" +{extra} more"));
}
text
}
fn surface_warnings(cfg: &Config) {
if cfg.warnings.is_empty() || WARNED.swap(true, Ordering::Relaxed) {
return;
}
for warning in &cfg.warnings {
notify_text(&format!("vet: {warning}"), UiNotifyLevel::Warning);
}
}
pub struct Vet;
impl Extension for Vet {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Green loop: run the project's checks (test / lint / typecheck) when the agent finishes \
and feed failures back so it keeps fixing until they pass. Checks come from \
agent_dir()/extensions/vet.json plus a trusted project's .prux/extensions/vet.json; \
/vet run|status|detect|add|remove|reset"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: config::CMD.to_string(),
description: "Green loop checks: /vet run|status|detect|add|remove|reset".to_string(),
busy_safe: true,
subcommands: command::SUBCOMMANDS.to_vec(),
}]
}
fn on_registered(&self) {
register_slash_command(EXT, config::CMD, command::handle);
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![
ExtensionHook::Boundary,
ExtensionHook::BeforeToolCall,
ExtensionHook::UserPrompt,
]
}
fn on_enabled_changed(&self, enabled: bool) {
if enabled {
let cfg = config::load(¤t_cwd());
surface_warnings(&cfg);
} else {
reset_turn_state();
WARNED.store(false, Ordering::Relaxed);
}
}
fn on_session_start(&self, cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
remember_cwd(cwd);
reset_turn_state();
WARNED.store(false, Ordering::Relaxed);
}
fn on_session_switched(&self, _session_path: Option<&str>, _messages: &[AgentMessage]) {
reset_turn_state();
WARNED.store(false, Ordering::Relaxed);
}
fn on_user_submit(&self, _text: &str, _busy: bool) {
reset_turn_state();
}
fn before_tool_call_ext(
&self,
name: &str,
_args: &Value,
_assistant_message: Option<&AgentMessage>,
_context: &[AgentMessage],
_all_args: &Value,
) -> std::result::Result<BeforeToolCallOutcome, ToolError> {
if is_write_tool(name) {
state().dirty = true;
}
Ok(BeforeToolCallOutcome::allow())
}
fn on_boundary(&self, event: &Value) -> Option<BoundaryOutcome> {
if event.get("type").and_then(Value::as_str) != Some("agent_before_settle") {
return None;
}
if event.get("agentScope").and_then(Value::as_str) != Some("main") {
return None;
}
if event.get("outcome").and_then(Value::as_str) == Some("aborted") {
reset_turn_state();
return None;
}
if event
.get("hasQueuedMessages")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return None;
}
settle()
}
fn settings(&self) -> Vec<ExtensionSetting> {
config::panel_settings(&config::load(¤t_cwd()))
}
fn apply_setting(&self, key: &str, value: &str) -> std::result::Result<(), String> {
config::apply_panel_choice(¤t_cwd(), key, value)
.map_err(|e| format!("write config failed: {e}"))
}
}
fn settle() -> Option<BoundaryOutcome> {
let cwd = current_cwd();
let cfg = config::load(&cwd);
surface_warnings(&cfg);
if !cfg.auto_run || !cfg.has_checks() {
return None;
}
let (iteration, dirty) = {
let mut st = state();
let dirty = st.dirty;
st.dirty = false;
(st.iteration, dirty)
};
if cfg.require_edits && !dirty {
return None;
}
let guard = RunGuard::acquire()?;
if cfg.announce != Announce::Off {
notify_text(
&format!(
"vet: running {} check{} ({}) {DEF_TRAILING_ELLIPSIS}",
cfg.checks.len(),
if cfg.checks.len() == 1 { "" } else { "s" },
check_names(&cfg)
),
UiNotifyLevel::Info,
);
}
let results = run::run_all(&cwd, &cfg.checks, cfg.timeout);
drop(guard);
record_run(&results);
if !run::has_blocking_failure(&results) {
if cfg.announce == Announce::Always {
notify_text(&run::verdict(&results), UiNotifyLevel::Success);
}
reset_turn_state();
return None;
}
let summary = run::verdict(&results);
if cfg.announce != Announce::Off {
notify_text(&summary, UiNotifyLevel::Warning);
}
if iteration >= cfg.max_iterations {
reset_turn_state();
notify_text(
&format!(
"vet: stopping after {} attempt{} — {summary}. Run /vet run to retry, or /vet \
status to review the configuration.",
iteration,
if iteration == 1 { "" } else { "s" }
),
UiNotifyLevel::Warning,
);
return None;
}
let fingerprint = run::failure_fingerprint(&results);
if fingerprint.is_some() && fingerprint == state().last_failure {
reset_turn_state();
notify_text(
&format!("vet: the same failures repeated — stopping the loop. {summary}"),
UiNotifyLevel::Warning,
);
return None;
}
let attempt = iteration + 1;
{
let mut st = state();
st.iteration = attempt;
st.last_failure = fingerprint;
}
let report = run::failure_report(&results, attempt, cfg.max_iterations);
Some(BoundaryOutcome::continue_with(vec![
AgentMessage::user_text(&report),
]))
}
fn record_run(results: &[CheckResult]) {
let mut st = state();
st.last_verdict = Some(run::verdict(results));
st.last_run_at = Some(Instant::now());
st.runs += 1;
}
fn last_run_label(at: Option<Instant>) -> String {
match at {
Some(at) => format!(
"{} ago",
format_duration_ms(at.elapsed().as_millis() as u64)
),
None => "never".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> crate::test_support::HomeGuard {
let guard = crate::test_support::HomeGuard::temp();
reset_turn_state();
{
let mut st = state();
st.last_verdict = None;
st.last_run_at = None;
st.runs = 0;
}
WARNED.store(false, Ordering::Relaxed);
guard
}
#[test]
fn extension_declares_the_green_loop_surface() {
let _g = fresh();
let ext = Vet;
assert_eq!(ext.name(), "vet");
assert!(!ext.default_enabled(), "跑测试必须由用户显式打开");
assert_eq!(ext.modes(), vec![ExtensionMode::Dev]);
assert!(ext.tools().is_empty(), "本扩展不给模型加工具");
let hooks = ext.hooks();
assert!(hooks.contains(&ExtensionHook::Boundary));
assert!(hooks.contains(&ExtensionHook::BeforeToolCall));
assert!(hooks.contains(&ExtensionHook::UserPrompt));
let commands = ext.commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "vet");
assert!(!commands[0].subcommands.is_empty());
}
#[test]
fn subcommands_match_the_parser() {
let names: Vec<&str> = command::SUBCOMMANDS.iter().map(|s| s.name).collect();
assert_eq!(
names,
vec![
"settings", "run", "status", "detect", "add", "remove", "reset"
]
);
}
#[test]
fn before_tool_call_only_flags_write_tools() {
let _g = fresh();
let ext = Vet;
ext.before_tool_call_ext("read", &Value::Null, None, &[], &Value::Null)
.unwrap();
assert!(!state().dirty, "读类工具不该置位");
ext.before_tool_call_ext("edit", &Value::Null, None, &[], &Value::Null)
.unwrap();
assert!(state().dirty, "写类工具应置位");
}
#[test]
fn boundary_ignores_non_settle_events_and_child_agents() {
let _g = fresh();
let ext = Vet;
assert!(
ext.on_boundary(&serde_json::json!({"type": "turn_end"}))
.is_none()
);
assert!(
ext.on_boundary(&serde_json::json!({
"type": "agent_before_settle",
"agentScope": "child"
}))
.is_none()
);
}
#[test]
fn boundary_skips_when_queued_input_or_aborted() {
let _g = fresh();
state().dirty = true;
let ext = Vet;
assert!(
ext.on_boundary(&serde_json::json!({
"type": "agent_before_settle",
"agentScope": "main",
"outcome": "aborted"
}))
.is_none()
);
assert!(
ext.on_boundary(&serde_json::json!({
"type": "agent_before_settle",
"agentScope": "main",
"hasQueuedMessages": true
}))
.is_none()
);
}
#[test]
fn user_submit_resets_the_turn_state() {
let _g = fresh();
{
let mut st = state();
st.iteration = 2;
st.dirty = true;
st.last_failure = Some(7);
}
Vet.on_user_submit("go on", false);
let st = state();
assert_eq!(st.iteration, 0);
assert!(!st.dirty);
assert_eq!(st.last_failure, None);
}
#[test]
fn run_guard_is_exclusive_and_released_on_drop() {
let _g = fresh();
let first = RunGuard::acquire();
assert!(first.is_some());
assert!(RunGuard::acquire().is_none(), "同一时刻只允许一组检查");
drop(first);
assert!(RunGuard::acquire().is_some());
}
#[test]
fn check_names_folds_the_tail() {
let cfg = Config {
checks: (0..6)
.map(|i| config::Check {
name: format!("c{i}"),
command: "true".to_string(),
optional: false,
})
.collect(),
..Config::default()
};
let text = check_names(&cfg);
assert!(text.starts_with("c0, c1, c2, c3"));
assert!(text.contains("+2 more"));
}
#[test]
fn last_run_label_is_never_before_the_first_run() {
assert_eq!(last_run_label(None), "never");
assert!(last_run_label(Some(Instant::now())).ends_with("ago"));
}
#[test]
fn settings_panel_exposes_the_five_switches() {
let _g = fresh();
let settings = Vet.settings();
let keys: Vec<&str> = settings.iter().map(|s| s.key.as_str()).collect();
assert_eq!(
keys,
vec![
"autoRun",
"maxIterations",
"timeout",
"announce",
"requireEdits"
]
);
for setting in &settings {
assert!(!setting.choices.is_empty(), "面板项都应可循环切换");
}
}
}