1use std::sync::{Mutex, MutexGuard};
4use zeroclaw_api::observability_traits::{Observer, ObserverEvent, ObserverMetric};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RecordedCall {
14 pub name: String,
16 pub arguments: String,
19 pub result: String,
21 pub success: bool,
23}
24
25#[derive(Default)]
28pub struct RecordingObserver {
29 tool_calls: Mutex<Vec<RecordedCall>>,
30 input_tokens: Mutex<u64>,
31 output_tokens: Mutex<u64>,
32}
33
34fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
35 match mutex.lock() {
36 Ok(guard) => guard,
37 Err(poisoned) => poisoned.into_inner(),
38 }
39}
40
41impl RecordingObserver {
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 pub fn calls(&self) -> Vec<RecordedCall> {
48 lock_recover(&self.tool_calls).clone()
49 }
50
51 pub fn tool_names(&self) -> Vec<String> {
53 lock_recover(&self.tool_calls)
54 .iter()
55 .map(|c| c.name.clone())
56 .collect()
57 }
58
59 pub fn all_tools_succeeded(&self) -> bool {
61 lock_recover(&self.tool_calls).iter().all(|c| c.success)
62 }
63
64 pub fn tokens(&self) -> (u64, u64) {
66 (
67 *lock_recover(&self.input_tokens),
68 *lock_recover(&self.output_tokens),
69 )
70 }
71}
72
73impl Observer for RecordingObserver {
74 fn record_event(&self, event: &ObserverEvent) {
75 match event {
76 ObserverEvent::ToolCall {
77 tool,
78 success,
79 arguments,
80 result,
81 ..
82 } => {
83 lock_recover(&self.tool_calls).push(RecordedCall {
84 name: tool.clone(),
85 arguments: arguments.clone().unwrap_or_default(),
86 result: result.clone().unwrap_or_default(),
87 success: *success,
88 });
89 }
90 ObserverEvent::LlmResponse {
91 input_tokens,
92 output_tokens,
93 ..
94 } => {
95 if let Some(i) = input_tokens {
96 *lock_recover(&self.input_tokens) += i;
97 }
98 if let Some(o) = output_tokens {
99 *lock_recover(&self.output_tokens) += o;
100 }
101 }
102 _ => {}
103 }
104 }
105
106 fn record_metric(&self, _metric: &ObserverMetric) {}
107
108 fn name(&self) -> &str {
109 "eval-recording"
110 }
111
112 fn as_any(&self) -> &dyn std::any::Any {
113 self
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use std::time::Duration;
121 use zeroclaw_api::observability_traits::{Observer, ObserverEvent};
122
123 fn tool_call_event(tool: &str, success: bool) -> ObserverEvent {
124 tool_call_event_with(tool, success, None, None)
125 }
126
127 fn tool_call_event_with(
128 tool: &str,
129 success: bool,
130 arguments: Option<&str>,
131 result: Option<&str>,
132 ) -> ObserverEvent {
133 ObserverEvent::ToolCall {
134 parent_agent_alias: None,
135 tool: tool.to_string(),
136 tool_call_id: None,
137 duration: Duration::from_millis(10),
138 success,
139 arguments: arguments.map(str::to_string),
140 result: result.map(str::to_string),
141 channel: None,
142 agent_alias: None,
143 turn_id: None,
144 }
145 }
146
147 fn llm_event(input: u64, output: u64) -> ObserverEvent {
148 ObserverEvent::LlmResponse {
149 parent_agent_alias: None,
150 model_provider: String::new(),
151 model: String::new(),
152 duration: Duration::from_millis(50),
153 success: true,
154 error_message: None,
155 input_tokens: Some(input),
156 output_tokens: Some(output),
157 messages: None,
158 channel: None,
159 agent_alias: None,
160 turn_id: None,
161 }
162 }
163
164 #[test]
165 fn new_observer_is_empty() {
166 let obs = RecordingObserver::new();
167 assert!(obs.tool_names().is_empty());
168 assert!(obs.all_tools_succeeded());
169 assert_eq!(obs.tokens(), (0, 0));
170 }
171
172 #[test]
173 fn tool_names_records_order() {
174 let obs = RecordingObserver::new();
175 obs.record_event(&tool_call_event("search", true));
176 obs.record_event(&tool_call_event("write", true));
177 assert_eq!(
178 obs.tool_names(),
179 vec!["search".to_string(), "write".to_string()]
180 );
181 }
182
183 #[test]
184 fn all_tools_succeeded_false_on_failure() {
185 let obs = RecordingObserver::new();
186 obs.record_event(&tool_call_event("ok", true));
187 obs.record_event(&tool_call_event("bad", false));
188 assert!(!obs.all_tools_succeeded());
189 }
190
191 #[test]
192 fn tokens_accumulate_across_llm_responses() {
193 let obs = RecordingObserver::new();
194 obs.record_event(&llm_event(100, 50));
195 obs.record_event(&llm_event(200, 80));
196 assert_eq!(obs.tokens(), (300, 130));
197 }
198
199 #[test]
200 fn unrelated_events_are_ignored() {
201 let obs = RecordingObserver::new();
202 obs.record_event(&ObserverEvent::HeartbeatTick);
203 assert!(obs.tool_names().is_empty());
204 assert_eq!(obs.tokens(), (0, 0));
205 }
206
207 #[test]
208 fn recording_observer_captures_tool_arguments_and_results() {
209 let obs = RecordingObserver::new();
212 obs.record_event(&tool_call_event_with(
213 "echo",
214 true,
215 Some(r#"{"message":"naïve café 日本語 ✓"}"#),
216 Some("naïve café 日本語 ✓"),
217 ));
218
219 let calls = obs.calls();
220 assert_eq!(calls.len(), 1);
221 assert_eq!(calls[0].name, "echo");
222 assert_eq!(calls[0].arguments, r#"{"message":"naïve café 日本語 ✓"}"#);
223 assert_eq!(calls[0].result, "naïve café 日本語 ✓");
224 assert!(calls[0].success);
225 }
226
227 #[test]
228 fn recorded_calls_default_to_empty_strings_when_boundary_omits_payload() {
229 let obs = RecordingObserver::new();
230 obs.record_event(&tool_call_event("echo", true));
231 let calls = obs.calls();
232 assert_eq!(calls.len(), 1);
233 assert!(calls[0].arguments.is_empty());
234 assert!(calls[0].result.is_empty());
235 }
236
237 #[test]
238 fn calls_preserve_dispatch_order_with_payloads() {
239 let obs = RecordingObserver::new();
240 obs.record_event(&tool_call_event_with(
241 "echo",
242 true,
243 Some(r#"{"message":"alpha"}"#),
244 Some("alpha"),
245 ));
246 obs.record_event(&tool_call_event_with(
247 "echo",
248 true,
249 Some(r#"{"message":"beta"}"#),
250 Some("beta"),
251 ));
252 let calls = obs.calls();
253 assert_eq!(calls.len(), 2);
254 assert!(calls[0].arguments.contains("alpha"));
255 assert!(calls[1].arguments.contains("beta"));
256 }
257}