use gate4agent_types::ProviderEvent;
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
const KEEP: usize = 32;
const OPEN_MAX: usize = 64;
const COMMAND_MAX: usize = 500;
#[derive(Clone, Debug, PartialEq)]
pub struct ToolCall {
pub instance_id: u64,
pub tool_call_id: String,
pub command: String,
pub is_error: bool,
pub duration_ms: Option<u64>,
pub not_run: Option<String>,
pub finished_at_ms: u64,
}
impl ToolCall {
pub fn to_json(&self) -> Value {
serde_json::json!({ "instance_id": self.instance_id, "tool_call_id": self.tool_call_id, "command": self.command, "is_error": self.is_error,
"duration_ms": self.duration_ms, "not_run": self.not_run, "finished_at_ms": self.finished_at_ms })
}
}
#[derive(Default)]
struct Workspace {
open: HashMap<(u64, String), (String, Instant)>,
done: VecDeque<ToolCall>,
}
static CALLS: Mutex<Option<HashMap<String, Workspace>>> = Mutex::new(None);
pub fn command_of(name: &str, input_json: &str) -> Option<String> {
let input: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
let words = |v: &Value| v.as_array().and_then(|a| a.iter().map(|x| x.as_str().map(str::to_owned)).collect::<Option<Vec<_>>>());
let line = match &input["command"] {
Value::String(c) => match words(&input["args"]) {
Some(args) if !args.is_empty() => Some(format!("{c} {}", args.join(" "))),
_ => Some(c.clone()),
},
v @ Value::Array(_) => words(v).map(|w| match w.as_slice() {
[sh, flag, line] if flag.starts_with('-') && flag.ends_with('c') && !sh.is_empty() => line.clone(),
_ => w.join(" "),
}),
_ => None,
}
.or_else(|| name.trim().strip_prefix('`').and_then(|n| n.strip_suffix('`')).map(str::to_owned))?;
let line = normalize(&line);
(!line.is_empty() && line.len() <= COMMAND_MAX * 4).then_some(line)
}
fn normalize(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn runs(command: &str, check: &str) -> bool {
let check = normalize(check);
!check.is_empty() && (command == check || command.ends_with(&format!("&& {check}")) || command.ends_with(&format!("; {check}")))
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
}
pub fn observe(workspace: &str, instance: u64, event: &ProviderEvent) {
if !workspace.starts_with("task-") {
return;
}
let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
let ws = guard.get_or_insert_with(HashMap::new).entry(workspace.to_owned()).or_default();
match event {
ProviderEvent::ToolStarted { id, name, input_json, .. } => {
let Some(command) = command_of(name, input_json) else { return };
if ws.open.len() >= OPEN_MAX {
if let Some(oldest) = ws.open.iter().min_by_key(|(_, (_, at))| *at).map(|(k, _)| k.clone()) {
ws.open.remove(&oldest);
}
}
ws.open.insert((instance, id.clone()), (command, Instant::now()));
}
ProviderEvent::ToolCompleted { id, is_error, duration_ms, non_execution_kind, .. } => {
let key = (instance, id.clone());
let Some((command, started)) = ws.open.get(&key).cloned() else { return };
let call = ToolCall {
instance_id: instance,
tool_call_id: id.chars().take(128).collect(),
command: command.chars().take(COMMAND_MAX).collect(),
is_error: *is_error,
duration_ms: duration_ms.or_else(|| Some(started.elapsed().as_millis() as u64)),
not_run: non_execution_kind.as_ref().map(|k| k.chars().take(32).collect()),
finished_at_ms: now_ms(),
};
ws.done.retain(|c| !(c.instance_id == instance && c.tool_call_id == call.tool_call_id));
ws.done.push_back(call);
while ws.done.len() > KEEP {
ws.done.pop_front();
}
}
_ => {}
}
}
pub fn take(workspace: &str, check: &str) -> Option<ToolCall> {
let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
let ws = guard.as_mut()?.get_mut(workspace)?;
let at = ws.done.iter().rposition(|c| runs(&c.command, check))?;
ws.done.remove(at)
}
pub fn forget_open(workspace: &str, instance: u64) {
let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
if let Some(ws) = guard.as_mut().and_then(|m| m.get_mut(workspace)) {
ws.open.retain(|(i, _), _| *i != instance);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn started(id: &str, name: &str, input: &str) -> ProviderEvent {
ProviderEvent::ToolStarted { id: id.into(), name: name.into(), input_json: input.into(), agent_id: None }
}
fn finished(id: &str, is_error: bool, not_run: Option<&str>) -> ProviderEvent {
ProviderEvent::ToolCompleted { id: id.into(), output: String::new(), is_error, duration_ms: None, agent_id: None, non_execution_kind: not_run.map(str::to_owned) }
}
#[test]
fn reads_the_command_shapes_acp_agents_send() {
assert_eq!(command_of("Bash", r#"{"command":"cargo test -q","description":"x"}"#).as_deref(), Some("cargo test -q"));
assert_eq!(command_of("t", r#"{"command":"cargo","args":["test","-q"]}"#).as_deref(), Some("cargo test -q"));
assert_eq!(command_of("t", r#"{"command":["bash","-lc","cargo test -q"]}"#).as_deref(), Some("cargo test -q"));
assert_eq!(command_of("`cargo test -q`", "{}").as_deref(), Some("cargo test -q"));
assert_eq!(command_of("Read", r#"{"file_path":"/x"}"#), None);
}
#[test]
fn matches_the_check_command_only() {
assert!(runs("cargo test -q", "cargo test -q"));
assert!(runs("cd /w/task-7 && cargo test -q", "cargo test -q"));
assert!(!runs("cargo test -q --no-run", "cargo test -q"));
assert!(!runs("echo cargo test -q", "cargo test -q"));
assert!(!runs("", ""));
}
#[test]
fn keeps_the_last_finished_call_of_the_check_in_a_task_worktree() {
let ws = "task-9001";
observe(ws, 1, &started("a", "Bash", r#"{"command":"cargo test"}"#));
observe(ws, 1, &started("b", "Bash", r#"{"command":"ls"}"#));
observe(ws, 1, &finished("a", false, None));
observe(ws, 1, &finished("a", true, None)); observe(ws, 1, &finished("b", false, None));
observe(ws, 1, &finished("zzz", false, None)); let call = take(ws, "cargo test").expect("the check call");
assert_eq!((call.tool_call_id.as_str(), call.is_error, call.instance_id), ("a", true, 1));
assert!(call.duration_ms.is_some() && call.finished_at_ms > 0);
assert_eq!(take(ws, "cargo test"), None, "one call backs one check run");
observe(ws, 2, &started("c", "Bash", r#"{"command":"cargo test"}"#));
forget_open(ws, 2);
observe(ws, 2, &finished("c", false, Some("cancelled")));
assert_eq!(take(ws, "cargo test"), None, "an ended session's open calls are gone");
observe("primary", 1, &started("d", "Bash", r#"{"command":"cargo test"}"#));
observe("primary", 1, &finished("d", false, None));
assert_eq!(take("primary", "cargo test"), None, "only task worktrees");
}
#[test]
fn a_call_that_never_ran_says_why() {
let ws = "task-9002";
observe(ws, 1, &started("x", "`make check`", "{}"));
observe(ws, 1, &finished("x", true, Some("user-rejected")));
let call = take(ws, "make check").unwrap();
assert_eq!(call.not_run.as_deref(), Some("user-rejected"));
assert_eq!(call.to_json()["not_run"], "user-rejected");
}
}