1use crate::agent::ToolCallTrace;
23use crate::message::{Block, Message, Role};
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct RecordedCall {
30 pub name: String,
31 pub input: Value,
32 pub output: String,
34 pub is_error: bool,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct Trajectory {
40 pub turns: Vec<String>,
42 pub calls: Vec<RecordedCall>,
44 pub final_text: String,
46 pub steered: bool,
55}
56
57pub fn extract(messages: &[Message]) -> Trajectory {
64 let mut t = Trajectory::default();
65 let mut pending: Vec<(String, String, Value)> = Vec::new();
67
68 for message in messages {
69 match message.role {
70 Role::Assistant => {
71 let text = message.text();
72 if !text.trim().is_empty() {
73 t.final_text = text;
74 }
75 for (id, name, input) in message.tool_uses() {
76 pending.push((id.to_string(), name.to_string(), input.clone()));
77 }
78 }
79 Role::User => {
80 let mut results = Vec::new();
81 let mut text = String::new();
82 for block in &message.content {
83 match block {
84 Block::ToolResult {
85 tool_use_id,
86 content,
87 is_error,
88 } => results.push((tool_use_id.clone(), content.clone(), *is_error)),
89 Block::Text { text: t } => text.push_str(t),
90 _ => {}
91 }
92 }
93
94 if results.is_empty() {
95 if !text.trim().is_empty() {
97 t.turns.push(text);
98 }
99 continue;
100 }
101
102 if !text.trim().is_empty() {
104 t.steered = true;
105 }
106 for (id, output, is_error) in results {
107 if let Some(i) = pending.iter().position(|(p, _, _)| *p == id) {
111 let (_, name, input) = pending.remove(i);
112 t.calls.push(RecordedCall {
113 name,
114 input,
115 output,
116 is_error,
117 });
118 }
119 }
120 }
121 }
122 }
123
124 t
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(tag = "kind", rename_all = "snake_case")]
130pub enum Divergence {
131 Tool {
133 index: usize,
134 expected: String,
135 actual: String,
136 },
137 Arguments {
142 index: usize,
143 tool: String,
144 expected: Value,
145 actual: Value,
146 },
147 Extra { index: usize, actual: String },
149 Missing { index: usize, expected: String },
151}
152
153impl Divergence {
154 pub fn index(&self) -> usize {
156 match self {
157 Divergence::Tool { index, .. }
158 | Divergence::Arguments { index, .. }
159 | Divergence::Extra { index, .. }
160 | Divergence::Missing { index, .. } => *index,
161 }
162 }
163
164 pub fn is_structural(&self) -> bool {
169 !matches!(self, Divergence::Arguments { .. })
170 }
171}
172
173pub fn diff_from(
179 base: usize,
180 recorded: &[RecordedCall],
181 replayed: &[ToolCallTrace],
182) -> Vec<Divergence> {
183 let mut out = diff(recorded, replayed);
184 for d in &mut out {
185 match d {
186 Divergence::Tool { index, .. }
187 | Divergence::Arguments { index, .. }
188 | Divergence::Extra { index, .. }
189 | Divergence::Missing { index, .. } => *index += base,
190 }
191 }
192 out
193}
194
195pub fn diff(recorded: &[RecordedCall], replayed: &[ToolCallTrace]) -> Vec<Divergence> {
201 let mut out = Vec::new();
202
203 for (index, (want, got)) in recorded.iter().zip(replayed.iter()).enumerate() {
204 if want.name != got.name {
205 out.push(Divergence::Tool {
206 index,
207 expected: want.name.clone(),
208 actual: got.name.clone(),
209 });
210 return out;
214 }
215 if !same_arguments(&want.input, &got.input) {
216 out.push(Divergence::Arguments {
217 index,
218 tool: want.name.clone(),
219 expected: want.input.clone(),
220 actual: got.input.clone(),
221 });
222 }
223 }
224
225 for (offset, extra) in replayed.iter().skip(recorded.len()).enumerate() {
226 out.push(Divergence::Extra {
227 index: recorded.len() + offset,
228 actual: extra.name.clone(),
229 });
230 }
231 for (offset, missing) in recorded.iter().skip(replayed.len()).enumerate() {
232 out.push(Divergence::Missing {
233 index: replayed.len() + offset,
234 expected: missing.name.clone(),
235 });
236 }
237
238 out
239}
240
241fn same_arguments(a: &Value, b: &Value) -> bool {
249 match (a, b) {
250 (Value::String(x), Value::String(y)) => x.trim() == y.trim(),
251 (Value::Object(x), Value::Object(y)) => {
252 x.len() == y.len()
253 && x.iter()
254 .all(|(k, v)| y.get(k).is_some_and(|w| same_arguments(v, w)))
255 }
256 (Value::Array(x), Value::Array(y)) => {
257 x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same_arguments(v, w))
258 }
259 _ => a == b,
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use serde_json::json;
267
268 fn call(id: &str, name: &str, input: Value) -> Block {
269 Block::ToolUse {
270 id: id.into(),
271 name: name.into(),
272 input,
273 }
274 }
275
276 fn result(id: &str, content: &str) -> Block {
277 Block::ToolResult {
278 tool_use_id: id.into(),
279 content: content.into(),
280 is_error: false,
281 }
282 }
283
284 fn trace(name: &str, input: Value) -> ToolCallTrace {
285 ToolCallTrace {
286 name: name.into(),
287 input,
288 is_error: false,
289 denied: false,
290 unknown: false,
291 staged: false,
292 }
293 }
294
295 #[test]
296 fn a_recorded_conversation_becomes_turns_and_calls() {
297 let messages = vec![
298 Message::user("what is in a.md?"),
299 Message::assistant(vec![call("t1", "fs_read", json!({"path": "a.md"}))]),
300 Message::tool_results(vec![result("t1", "hello")]),
301 Message::assistant(vec![Block::text("it says hello")]),
302 ];
303
304 let t = extract(&messages);
305
306 assert_eq!(t.turns, vec!["what is in a.md?"]);
309 assert_eq!(t.calls.len(), 1);
310 assert_eq!(t.calls[0].name, "fs_read");
311 assert_eq!(t.calls[0].output, "hello");
312 assert_eq!(t.final_text, "it says hello");
313 assert!(!t.steered);
314 }
315
316 #[test]
317 fn several_user_turns_are_all_kept_in_order() {
318 let messages = vec![
319 Message::user("first"),
320 Message::assistant(vec![Block::text("ok")]),
321 Message::user("second"),
322 Message::assistant(vec![Block::text("ok again")]),
323 ];
324
325 assert_eq!(extract(&messages).turns, vec!["first", "second"]);
326 }
327
328 #[test]
329 fn results_are_paired_by_id_not_by_arrival_order() {
330 let messages = vec![
334 Message::user("read both"),
335 Message::assistant(vec![
336 call("t1", "fs_read", json!({"path": "a.md"})),
337 call("t2", "fs_read", json!({"path": "b.md"})),
338 ]),
339 Message::tool_results(vec![result("t2", "B"), result("t1", "A")]),
340 ];
341
342 let t = extract(&messages);
343
344 assert_eq!(t.calls.len(), 2);
345 let by_path = |p: &str| {
346 t.calls
347 .iter()
348 .find(|c| c.input["path"] == p)
349 .unwrap_or_else(|| panic!("no call for {p}"))
350 };
351 assert_eq!(by_path("a.md").output, "A");
352 assert_eq!(by_path("b.md").output, "B");
353 }
354
355 #[test]
356 fn steering_is_flagged_rather_than_mistaken_for_a_turn() {
357 let messages = vec![
361 Message::user("start"),
362 Message::assistant(vec![call("t1", "shell", json!({"command": "sleep 6"}))]),
363 Message::tool_results(vec![
364 result("t1", ""),
365 Block::text("change of plan: just say PIVOT"),
366 ]),
367 Message::assistant(vec![Block::text("PIVOT")]),
368 ];
369
370 let t = extract(&messages);
371
372 assert_eq!(t.turns, vec!["start"], "steering became a user turn");
373 assert!(t.steered, "a steered recording must say so");
374 }
375
376 #[test]
377 fn an_identical_replay_has_nothing_to_report() {
378 let recorded = vec![RecordedCall {
379 name: "fs_read".into(),
380 input: json!({"path": "a.md"}),
381 output: "hello".into(),
382 is_error: false,
383 }];
384 let replayed = vec![trace("fs_read", json!({"path": "a.md"}))];
385
386 assert!(diff(&recorded, &replayed).is_empty());
387 }
388
389 #[test]
390 fn a_different_tool_stops_the_comparison_rather_than_cascading() {
391 let recorded = vec![
394 RecordedCall {
395 name: "fs_read".into(),
396 input: json!({}),
397 output: String::new(),
398 is_error: false,
399 },
400 RecordedCall {
401 name: "fs_read".into(),
402 input: json!({}),
403 output: String::new(),
404 is_error: false,
405 },
406 RecordedCall {
407 name: "fs_read".into(),
408 input: json!({}),
409 output: String::new(),
410 is_error: false,
411 },
412 ];
413 let replayed = vec![
414 trace("shell", json!({})),
415 trace("shell", json!({})),
416 trace("shell", json!({})),
417 ];
418
419 let d = diff(&recorded, &replayed);
420
421 assert_eq!(d.len(), 1);
422 assert_eq!(
423 d[0],
424 Divergence::Tool {
425 index: 0,
426 expected: "fs_read".into(),
427 actual: "shell".into()
428 }
429 );
430 assert!(d[0].is_structural());
431 }
432
433 #[test]
434 fn the_same_tool_with_different_arguments_is_reported_but_not_structural() {
435 let recorded = vec![RecordedCall {
436 name: "fs_read".into(),
437 input: json!({"path": "a.md"}),
438 output: String::new(),
439 is_error: false,
440 }];
441 let replayed = vec![trace("fs_read", json!({"path": "./a.md"}))];
442
443 let d = diff(&recorded, &replayed);
444
445 assert_eq!(d.len(), 1);
446 assert!(!d[0].is_structural());
449 }
450
451 #[test]
452 fn running_long_and_stopping_early_are_different_findings() {
453 let one = |name: &str| RecordedCall {
454 name: name.into(),
455 input: json!({}),
456 output: String::new(),
457 is_error: false,
458 };
459
460 let extra = diff(
461 &[one("fs_read")],
462 &[trace("fs_read", json!({})), trace("shell", json!({}))],
463 );
464 assert_eq!(
465 extra,
466 vec![Divergence::Extra {
467 index: 1,
468 actual: "shell".into()
469 }]
470 );
471
472 let missing = diff(
473 &[one("fs_read"), one("shell")],
474 &[trace("fs_read", json!({}))],
475 );
476 assert_eq!(
477 missing,
478 vec![Divergence::Missing {
479 index: 1,
480 expected: "shell".into()
481 }]
482 );
483 }
484
485 #[test]
486 fn order_is_part_of_the_trajectory_not_an_incidental_detail() {
487 let one = |p: &str| RecordedCall {
490 name: "fs_read".into(),
491 input: json!({"path": p}),
492 output: String::new(),
493 is_error: false,
494 };
495 let d = diff(
496 &[one("a.md"), one("b.md")],
497 &[
498 trace("fs_read", json!({"path": "b.md"})),
499 trace("fs_read", json!({"path": "a.md"})),
500 ],
501 );
502
503 assert_eq!(d.len(), 2, "a reordering went unreported");
504 }
505
506 #[test]
509 fn diff_from_reports_indices_in_the_full_recordings_coordinates() {
510 let recorded_tail = vec![
511 RecordedCall {
512 name: "fs_read".into(),
513 input: json!({}),
514 output: String::new(),
515 is_error: false,
516 },
517 RecordedCall {
518 name: "fs_read".into(),
519 input: json!({}),
520 output: String::new(),
521 is_error: false,
522 },
523 ];
524 let replayed = vec![trace("fs_read", json!({})), trace("shell", json!({}))];
525
526 let d = diff_from(10, &recorded_tail, &replayed);
527
528 assert_eq!(
529 d,
530 vec![Divergence::Tool {
531 index: 11,
532 expected: "fs_read".into(),
533 actual: "shell".into()
534 }],
535 "a divergence at tail position 1 sits at recording position 11"
536 );
537 assert_eq!(
539 diff_from(0, &recorded_tail, &replayed),
540 diff(&recorded_tail, &replayed)
541 );
542 }
543
544 #[test]
545 fn whitespace_in_arguments_does_not_count_as_a_change() {
546 let recorded = vec![RecordedCall {
547 name: "shell".into(),
548 input: json!({"command": "ls -la"}),
549 output: String::new(),
550 is_error: false,
551 }];
552 let replayed = vec![trace("shell", json!({"command": " ls -la "}))];
553
554 assert!(diff(&recorded, &replayed).is_empty());
555 }
556}