1use gate4agent_types::ProviderEvent;
12use serde_json::Value;
13use std::collections::{HashMap, VecDeque};
14use std::sync::Mutex;
15use std::time::{Instant, SystemTime, UNIX_EPOCH};
16
17const KEEP: usize = 32;
19const OPEN_MAX: usize = 64;
21const COMMAND_MAX: usize = 500;
22
23#[derive(Clone, Debug, PartialEq)]
25pub struct ToolCall {
26 pub instance_id: u64,
27 pub tool_call_id: String,
28 pub command: String,
29 pub is_error: bool,
30 pub duration_ms: Option<u64>,
31 pub not_run: Option<String>,
33 pub finished_at_ms: u64,
34}
35
36impl ToolCall {
37 pub fn to_json(&self) -> Value {
39 serde_json::json!({ "instance_id": self.instance_id, "tool_call_id": self.tool_call_id, "command": self.command, "is_error": self.is_error,
40 "duration_ms": self.duration_ms, "not_run": self.not_run, "finished_at_ms": self.finished_at_ms })
41 }
42}
43
44#[derive(Default)]
45struct Workspace {
46 open: HashMap<(u64, String), (String, Instant)>,
47 done: VecDeque<ToolCall>,
48}
49
50static CALLS: Mutex<Option<HashMap<String, Workspace>>> = Mutex::new(None);
51
52pub fn command_of(name: &str, input_json: &str) -> Option<String> {
55 let input: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
56 let words = |v: &Value| v.as_array().and_then(|a| a.iter().map(|x| x.as_str().map(str::to_owned)).collect::<Option<Vec<_>>>());
57 let line = match &input["command"] {
58 Value::String(c) => match words(&input["args"]) {
59 Some(args) if !args.is_empty() => Some(format!("{c} {}", args.join(" "))),
60 _ => Some(c.clone()),
61 },
62 v @ Value::Array(_) => words(v).map(|w| match w.as_slice() {
63 [sh, flag, line] if flag.starts_with('-') && flag.ends_with('c') && !sh.is_empty() => line.clone(),
65 _ => w.join(" "),
66 }),
67 _ => None,
68 }
69 .or_else(|| name.trim().strip_prefix('`').and_then(|n| n.strip_suffix('`')).map(str::to_owned))?;
70 let line = normalize(&line);
71 (!line.is_empty() && line.len() <= COMMAND_MAX * 4).then_some(line)
72}
73
74fn normalize(s: &str) -> String {
75 s.split_whitespace().collect::<Vec<_>>().join(" ")
76}
77
78pub fn runs(command: &str, check: &str) -> bool {
80 let check = normalize(check);
81 !check.is_empty() && (command == check || command.ends_with(&format!("&& {check}")) || command.ends_with(&format!("; {check}")))
82}
83
84fn now_ms() -> u64 {
85 SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
86}
87
88pub fn observe(workspace: &str, instance: u64, event: &ProviderEvent) {
90 if !workspace.starts_with("task-") {
91 return;
92 }
93 let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
94 let ws = guard.get_or_insert_with(HashMap::new).entry(workspace.to_owned()).or_default();
95 match event {
96 ProviderEvent::ToolStarted { id, name, input_json, .. } => {
97 let Some(command) = command_of(name, input_json) else { return };
98 if ws.open.len() >= OPEN_MAX {
99 if let Some(oldest) = ws.open.iter().min_by_key(|(_, (_, at))| *at).map(|(k, _)| k.clone()) {
100 ws.open.remove(&oldest);
101 }
102 }
103 ws.open.insert((instance, id.clone()), (command, Instant::now()));
104 }
105 ProviderEvent::ToolCompleted { id, is_error, duration_ms, non_execution_kind, .. } => {
106 let key = (instance, id.clone());
107 let Some((command, started)) = ws.open.get(&key).cloned() else { return };
108 let call = ToolCall {
109 instance_id: instance,
110 tool_call_id: id.chars().take(128).collect(),
111 command: command.chars().take(COMMAND_MAX).collect(),
112 is_error: *is_error,
113 duration_ms: duration_ms.or_else(|| Some(started.elapsed().as_millis() as u64)),
114 not_run: non_execution_kind.as_ref().map(|k| k.chars().take(32).collect()),
115 finished_at_ms: now_ms(),
116 };
117 ws.done.retain(|c| !(c.instance_id == instance && c.tool_call_id == call.tool_call_id));
119 ws.done.push_back(call);
120 while ws.done.len() > KEEP {
121 ws.done.pop_front();
122 }
123 }
124 _ => {}
125 }
126}
127
128pub fn take(workspace: &str, check: &str) -> Option<ToolCall> {
130 let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
131 let ws = guard.as_mut()?.get_mut(workspace)?;
132 let at = ws.done.iter().rposition(|c| runs(&c.command, check))?;
133 ws.done.remove(at)
134}
135
136pub fn forget_open(workspace: &str, instance: u64) {
138 let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
139 if let Some(ws) = guard.as_mut().and_then(|m| m.get_mut(workspace)) {
140 ws.open.retain(|(i, _), _| *i != instance);
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn started(id: &str, name: &str, input: &str) -> ProviderEvent {
149 ProviderEvent::ToolStarted { id: id.into(), name: name.into(), input_json: input.into(), agent_id: None }
150 }
151
152 fn finished(id: &str, is_error: bool, not_run: Option<&str>) -> ProviderEvent {
153 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) }
154 }
155
156 #[test]
157 fn reads_the_command_shapes_acp_agents_send() {
158 assert_eq!(command_of("Bash", r#"{"command":"cargo test -q","description":"x"}"#).as_deref(), Some("cargo test -q"));
159 assert_eq!(command_of("t", r#"{"command":"cargo","args":["test","-q"]}"#).as_deref(), Some("cargo test -q"));
160 assert_eq!(command_of("t", r#"{"command":["bash","-lc","cargo test -q"]}"#).as_deref(), Some("cargo test -q"));
161 assert_eq!(command_of("`cargo test -q`", "{}").as_deref(), Some("cargo test -q"));
162 assert_eq!(command_of("Read", r#"{"file_path":"/x"}"#), None);
163 }
164
165 #[test]
166 fn matches_the_check_command_only() {
167 assert!(runs("cargo test -q", "cargo test -q"));
168 assert!(runs("cd /w/task-7 && cargo test -q", "cargo test -q"));
169 assert!(!runs("cargo test -q --no-run", "cargo test -q"));
170 assert!(!runs("echo cargo test -q", "cargo test -q"));
171 assert!(!runs("", ""));
172 }
173
174 #[test]
175 fn keeps_the_last_finished_call_of_the_check_in_a_task_worktree() {
176 let ws = "task-9001";
177 observe(ws, 1, &started("a", "Bash", r#"{"command":"cargo test"}"#));
178 observe(ws, 1, &started("b", "Bash", r#"{"command":"ls"}"#));
179 observe(ws, 1, &finished("a", false, None));
180 observe(ws, 1, &finished("a", true, None)); observe(ws, 1, &finished("b", false, None));
182 observe(ws, 1, &finished("zzz", false, None)); let call = take(ws, "cargo test").expect("the check call");
184 assert_eq!((call.tool_call_id.as_str(), call.is_error, call.instance_id), ("a", true, 1));
185 assert!(call.duration_ms.is_some() && call.finished_at_ms > 0);
186 assert_eq!(take(ws, "cargo test"), None, "one call backs one check run");
187 observe(ws, 2, &started("c", "Bash", r#"{"command":"cargo test"}"#));
188 forget_open(ws, 2);
189 observe(ws, 2, &finished("c", false, Some("cancelled")));
190 assert_eq!(take(ws, "cargo test"), None, "an ended session's open calls are gone");
191 observe("primary", 1, &started("d", "Bash", r#"{"command":"cargo test"}"#));
192 observe("primary", 1, &finished("d", false, None));
193 assert_eq!(take("primary", "cargo test"), None, "only task worktrees");
194 }
195
196 #[test]
197 fn a_call_that_never_ran_says_why() {
198 let ws = "task-9002";
199 observe(ws, 1, &started("x", "`make check`", "{}"));
200 observe(ws, 1, &finished("x", true, Some("user-rejected")));
201 let call = take(ws, "make check").unwrap();
202 assert_eq!(call.not_run.as_deref(), Some("user-rejected"));
203 assert_eq!(call.to_json()["not_run"], "user-rejected");
204 }
205}