1use oxidized_state::storage_traits::RunEvent;
8use serde_json::Value;
9
10#[derive(Debug, Clone)]
12pub struct ToolCallEntry {
13 pub seq: u64,
15 pub tool_name: String,
17 pub payload: Value,
19}
20
21#[derive(Debug, Clone)]
23pub struct ParamChange {
24 pub pointer: String,
26 pub value_a: Value,
28 pub value_b: Value,
30}
31
32#[derive(Debug, Clone)]
34pub enum ToolCallChange {
35 Added { entry: ToolCallEntry },
37 Removed { entry: ToolCallEntry },
39 Reordered {
41 tool_name: String,
42 seq_a: u64,
43 seq_b: u64,
44 },
45 ParamDelta {
47 tool_name: String,
48 seq_a: u64,
49 seq_b: u64,
50 changes: Vec<ParamChange>,
51 },
52}
53
54#[derive(Debug, Clone)]
56pub struct DiffSummary {
57 pub run_id_a: String,
58 pub run_id_b: String,
59 pub changes: Vec<ToolCallChange>,
60 pub identical: bool,
61}
62
63fn extract_tool_calls(events: &[RunEvent]) -> Vec<ToolCallEntry> {
69 events
70 .iter()
71 .filter(|e| e.kind == "tool_called")
72 .map(|e| ToolCallEntry {
73 seq: e.seq,
74 tool_name: e
75 .payload
76 .get("tool_name")
77 .and_then(|v| v.as_str())
78 .unwrap_or("unknown")
79 .to_string(),
80 payload: e.payload.clone(),
81 })
82 .collect()
83}
84
85fn lcs_alignment(calls_a: &[ToolCallEntry], calls_b: &[ToolCallEntry]) -> Vec<(usize, usize)> {
90 let m = calls_a.len();
91 let n = calls_b.len();
92
93 if m == 0 || n == 0 {
94 return Vec::new();
95 }
96
97 let mut dp = vec![vec![0usize; n + 1]; m + 1];
99
100 for i in 1..=m {
101 for j in 1..=n {
102 if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
103 dp[i][j] = dp[i - 1][j - 1] + 1;
104 } else {
105 dp[i][j] = dp[i][j - 1].max(dp[i - 1][j]);
106 }
107 }
108 }
109
110 let mut alignment = Vec::new();
112 let mut i = m;
113 let mut j = n;
114
115 while i > 0 && j > 0 {
116 if calls_a[i - 1].tool_name == calls_b[j - 1].tool_name {
117 alignment.push((i - 1, j - 1));
118 i -= 1;
119 j -= 1;
120 } else if dp[i][j - 1] > dp[i - 1][j] {
121 j -= 1;
122 } else {
123 i -= 1;
124 }
125 }
126
127 alignment.reverse();
128 alignment
129}
130
131fn json_diff(prefix: &str, val_a: &Value, val_b: &Value) -> Vec<ParamChange> {
135 if val_a == val_b {
136 return Vec::new();
137 }
138
139 match (val_a, val_b) {
140 (Value::Object(obj_a), Value::Object(obj_b)) => {
141 let mut changes = Vec::new();
142 let mut keys = std::collections::HashSet::new();
143 keys.extend(obj_a.keys().cloned());
144 keys.extend(obj_b.keys().cloned());
145
146 for key in keys {
147 let val_a_inner = obj_a.get(&key).unwrap_or(&Value::Null);
148 let val_b_inner = obj_b.get(&key).unwrap_or(&Value::Null);
149 let path = if prefix.is_empty() {
150 format!("/{}", key)
151 } else {
152 format!("{}/{}", prefix, key)
153 };
154 changes.extend(json_diff(&path, val_a_inner, val_b_inner));
155 }
156 changes
157 }
158 (Value::Array(arr_a), Value::Array(arr_b)) => {
159 let mut changes = Vec::new();
160 let max_len = arr_a.len().max(arr_b.len());
161
162 for i in 0..max_len {
163 let val_a_inner = arr_a.get(i).unwrap_or(&Value::Null);
164 let val_b_inner = arr_b.get(i).unwrap_or(&Value::Null);
165 let path = format!("{}/{}", prefix, i);
166 changes.extend(json_diff(&path, val_a_inner, val_b_inner));
167 }
168 changes
169 }
170 _ => {
171 vec![ParamChange {
172 pointer: if prefix.is_empty() {
173 "/".to_string()
174 } else {
175 prefix.to_string()
176 },
177 value_a: val_a.clone(),
178 value_b: val_b.clone(),
179 }]
180 }
181 }
182}
183
184pub fn diff_tool_calls(
197 run_id_a: &str,
198 events_a: &[RunEvent],
199 run_id_b: &str,
200 events_b: &[RunEvent],
201) -> DiffSummary {
202 let calls_a = extract_tool_calls(events_a);
203 let calls_b = extract_tool_calls(events_b);
204
205 let alignment = lcs_alignment(&calls_a, &calls_b);
206
207 let mut aligned_a: std::collections::HashSet<usize> = std::collections::HashSet::new();
209 let mut aligned_b: std::collections::HashSet<usize> = std::collections::HashSet::new();
210 for (i_a, i_b) in &alignment {
211 aligned_a.insert(*i_a);
212 aligned_b.insert(*i_b);
213 }
214
215 let mut changes = Vec::new();
216
217 for (i, call) in calls_a.iter().enumerate() {
219 if !aligned_a.contains(&i) {
220 changes.push(ToolCallChange::Removed {
221 entry: call.clone(),
222 });
223 }
224 }
225
226 for (idx, (i_a, i_b)) in alignment.iter().enumerate() {
229 let call_a = &calls_a[*i_a];
230 let call_b = &calls_b[*i_b];
231
232 let is_reordered = if idx > 0 {
235 let (prev_i_a, prev_i_b) = alignment[idx - 1];
236 let prev_call_a = &calls_a[prev_i_a];
237 let prev_call_b = &calls_b[prev_i_b];
238
239 (*i_a > prev_i_a) != (call_a.seq > prev_call_a.seq)
242 || (*i_b > prev_i_b) != (call_b.seq > prev_call_b.seq)
243 } else {
244 false
245 };
246
247 if is_reordered {
248 changes.push(ToolCallChange::Reordered {
249 tool_name: call_a.tool_name.clone(),
250 seq_a: call_a.seq,
251 seq_b: call_b.seq,
252 });
253 } else {
254 let param_changes = json_diff("", &call_a.payload, &call_b.payload);
256 if !param_changes.is_empty() {
257 changes.push(ToolCallChange::ParamDelta {
258 tool_name: call_a.tool_name.clone(),
259 seq_a: call_a.seq,
260 seq_b: call_b.seq,
261 changes: param_changes,
262 });
263 }
264 }
265 }
266
267 for (i, call) in calls_b.iter().enumerate() {
269 if !aligned_b.contains(&i) {
270 changes.push(ToolCallChange::Added {
271 entry: call.clone(),
272 });
273 }
274 }
275
276 let identical = changes.is_empty();
277
278 DiffSummary {
279 run_id_a: run_id_a.to_string(),
280 run_id_b: run_id_b.to_string(),
281 changes,
282 identical,
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use chrono::Utc;
290
291 fn make_tool_event(seq: u64, tool_name: &str, extra_payload: Option<Value>) -> RunEvent {
292 let mut payload = serde_json::json!({
293 "tool_name": tool_name,
294 });
295 if let Some(extra) = extra_payload {
296 if let Value::Object(ref mut obj) = payload {
297 if let Value::Object(ref extra_obj) = extra {
298 for (k, v) in extra_obj.iter() {
299 obj.insert(k.clone(), v.clone());
300 }
301 }
302 }
303 }
304 RunEvent {
305 seq,
306 kind: "tool_called".to_string(),
307 payload,
308 timestamp: Utc::now(),
309 }
310 }
311
312 #[test]
313 fn test_identical_runs_no_diff() {
314 let events_a = vec![
315 make_tool_event(1, "search", None),
316 make_tool_event(2, "fetch", None),
317 ];
318 let events_b = vec![
319 make_tool_event(1, "search", None),
320 make_tool_event(2, "fetch", None),
321 ];
322
323 let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
324
325 assert!(diff.identical);
326 assert!(diff.changes.is_empty());
327 }
328
329 #[test]
330 fn test_tool_added() {
331 let events_a = vec![
332 make_tool_event(1, "search", None),
333 make_tool_event(2, "fetch", None),
334 ];
335 let events_b = vec![
336 make_tool_event(1, "search", None),
337 make_tool_event(2, "translate", None),
338 make_tool_event(3, "fetch", None),
339 ];
340
341 let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
342
343 assert!(!diff.identical);
344 assert_eq!(diff.changes.len(), 1);
345
346 match &diff.changes[0] {
347 ToolCallChange::Added { entry } => {
348 assert_eq!(entry.tool_name, "translate");
349 }
350 other => panic!("Expected Added, got {:?}", other),
351 }
352 }
353
354 #[test]
355 fn test_tool_removed() {
356 let events_a = vec![
357 make_tool_event(1, "search", None),
358 make_tool_event(2, "translate", None),
359 make_tool_event(3, "fetch", None),
360 ];
361 let events_b = vec![
362 make_tool_event(1, "search", None),
363 make_tool_event(2, "fetch", None),
364 ];
365
366 let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
367
368 assert!(!diff.identical);
369 assert_eq!(diff.changes.len(), 1);
370
371 match &diff.changes[0] {
372 ToolCallChange::Removed { entry } => {
373 assert_eq!(entry.tool_name, "translate");
374 }
375 other => panic!("Expected Removed, got {:?}", other),
376 }
377 }
378
379 #[test]
380 fn test_param_delta() {
381 let events_a = vec![make_tool_event(
382 1,
383 "search",
384 Some(serde_json::json!({"query": "cats"})),
385 )];
386 let events_b = vec![make_tool_event(
387 1,
388 "search",
389 Some(serde_json::json!({"query": "dogs"})),
390 )];
391
392 let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
393
394 assert!(!diff.identical);
395 assert_eq!(diff.changes.len(), 1);
396
397 match &diff.changes[0] {
398 ToolCallChange::ParamDelta {
399 tool_name, changes, ..
400 } => {
401 assert_eq!(tool_name, "search");
402 assert_eq!(changes.len(), 1);
403 assert_eq!(changes[0].pointer, "/query");
404 }
405 other => panic!("Expected ParamDelta, got {:?}", other),
406 }
407 }
408
409 #[test]
410 fn test_symmetry_property() {
411 let events_a = vec![
412 make_tool_event(1, "search", None),
413 make_tool_event(2, "fetch", None),
414 ];
415 let events_b = vec![
416 make_tool_event(1, "search", None),
417 make_tool_event(2, "translate", None),
418 make_tool_event(3, "fetch", None),
419 ];
420
421 let diff_ab = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
422 let diff_ba = diff_tool_calls("run_b", &events_b, "run_a", &events_a);
423
424 assert!(matches!(&diff_ab.changes[0], ToolCallChange::Added { .. }));
426
427 assert!(matches!(
429 &diff_ba.changes[0],
430 ToolCallChange::Removed { .. }
431 ));
432 }
433
434 #[test]
435 fn test_empty_vs_nonempty() {
436 let events_a = vec![];
437 let events_b = vec![
438 make_tool_event(1, "search", None),
439 make_tool_event(2, "fetch", None),
440 ];
441
442 let diff = diff_tool_calls("run_a", &events_a, "run_b", &events_b);
443
444 assert!(!diff.identical);
445 assert_eq!(diff.changes.len(), 2);
446
447 for change in &diff.changes {
448 assert!(matches!(change, ToolCallChange::Added { .. }));
449 }
450 }
451}