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(recorded: &[RecordedCall], replayed: &[ToolCallTrace]) -> Vec<Divergence> {
179 let mut out = Vec::new();
180
181 for (index, (want, got)) in recorded.iter().zip(replayed.iter()).enumerate() {
182 if want.name != got.name {
183 out.push(Divergence::Tool {
184 index,
185 expected: want.name.clone(),
186 actual: got.name.clone(),
187 });
188 return out;
192 }
193 if !same_arguments(&want.input, &got.input) {
194 out.push(Divergence::Arguments {
195 index,
196 tool: want.name.clone(),
197 expected: want.input.clone(),
198 actual: got.input.clone(),
199 });
200 }
201 }
202
203 for (offset, extra) in replayed.iter().skip(recorded.len()).enumerate() {
204 out.push(Divergence::Extra {
205 index: recorded.len() + offset,
206 actual: extra.name.clone(),
207 });
208 }
209 for (offset, missing) in recorded.iter().skip(replayed.len()).enumerate() {
210 out.push(Divergence::Missing {
211 index: replayed.len() + offset,
212 expected: missing.name.clone(),
213 });
214 }
215
216 out
217}
218
219fn same_arguments(a: &Value, b: &Value) -> bool {
227 match (a, b) {
228 (Value::String(x), Value::String(y)) => x.trim() == y.trim(),
229 (Value::Object(x), Value::Object(y)) => {
230 x.len() == y.len()
231 && x.iter()
232 .all(|(k, v)| y.get(k).is_some_and(|w| same_arguments(v, w)))
233 }
234 (Value::Array(x), Value::Array(y)) => {
235 x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same_arguments(v, w))
236 }
237 _ => a == b,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use serde_json::json;
245
246 fn call(id: &str, name: &str, input: Value) -> Block {
247 Block::ToolUse {
248 id: id.into(),
249 name: name.into(),
250 input,
251 }
252 }
253
254 fn result(id: &str, content: &str) -> Block {
255 Block::ToolResult {
256 tool_use_id: id.into(),
257 content: content.into(),
258 is_error: false,
259 }
260 }
261
262 fn trace(name: &str, input: Value) -> ToolCallTrace {
263 ToolCallTrace {
264 name: name.into(),
265 input,
266 is_error: false,
267 denied: false,
268 unknown: false,
269 staged: false,
270 }
271 }
272
273 #[test]
274 fn a_recorded_conversation_becomes_turns_and_calls() {
275 let messages = vec![
276 Message::user("what is in a.md?"),
277 Message::assistant(vec![call("t1", "fs_read", json!({"path": "a.md"}))]),
278 Message::tool_results(vec![result("t1", "hello")]),
279 Message::assistant(vec![Block::text("it says hello")]),
280 ];
281
282 let t = extract(&messages);
283
284 assert_eq!(t.turns, vec!["what is in a.md?"]);
287 assert_eq!(t.calls.len(), 1);
288 assert_eq!(t.calls[0].name, "fs_read");
289 assert_eq!(t.calls[0].output, "hello");
290 assert_eq!(t.final_text, "it says hello");
291 assert!(!t.steered);
292 }
293
294 #[test]
295 fn several_user_turns_are_all_kept_in_order() {
296 let messages = vec![
297 Message::user("first"),
298 Message::assistant(vec![Block::text("ok")]),
299 Message::user("second"),
300 Message::assistant(vec![Block::text("ok again")]),
301 ];
302
303 assert_eq!(extract(&messages).turns, vec!["first", "second"]);
304 }
305
306 #[test]
307 fn results_are_paired_by_id_not_by_arrival_order() {
308 let messages = vec![
312 Message::user("read both"),
313 Message::assistant(vec![
314 call("t1", "fs_read", json!({"path": "a.md"})),
315 call("t2", "fs_read", json!({"path": "b.md"})),
316 ]),
317 Message::tool_results(vec![result("t2", "B"), result("t1", "A")]),
318 ];
319
320 let t = extract(&messages);
321
322 assert_eq!(t.calls.len(), 2);
323 let by_path = |p: &str| {
324 t.calls
325 .iter()
326 .find(|c| c.input["path"] == p)
327 .unwrap_or_else(|| panic!("no call for {p}"))
328 };
329 assert_eq!(by_path("a.md").output, "A");
330 assert_eq!(by_path("b.md").output, "B");
331 }
332
333 #[test]
334 fn steering_is_flagged_rather_than_mistaken_for_a_turn() {
335 let messages = vec![
339 Message::user("start"),
340 Message::assistant(vec![call("t1", "shell", json!({"command": "sleep 6"}))]),
341 Message::tool_results(vec![
342 result("t1", ""),
343 Block::text("change of plan: just say PIVOT"),
344 ]),
345 Message::assistant(vec![Block::text("PIVOT")]),
346 ];
347
348 let t = extract(&messages);
349
350 assert_eq!(t.turns, vec!["start"], "steering became a user turn");
351 assert!(t.steered, "a steered recording must say so");
352 }
353
354 #[test]
355 fn an_identical_replay_has_nothing_to_report() {
356 let recorded = vec![RecordedCall {
357 name: "fs_read".into(),
358 input: json!({"path": "a.md"}),
359 output: "hello".into(),
360 is_error: false,
361 }];
362 let replayed = vec![trace("fs_read", json!({"path": "a.md"}))];
363
364 assert!(diff(&recorded, &replayed).is_empty());
365 }
366
367 #[test]
368 fn a_different_tool_stops_the_comparison_rather_than_cascading() {
369 let recorded = vec![
372 RecordedCall {
373 name: "fs_read".into(),
374 input: json!({}),
375 output: String::new(),
376 is_error: false,
377 },
378 RecordedCall {
379 name: "fs_read".into(),
380 input: json!({}),
381 output: String::new(),
382 is_error: false,
383 },
384 RecordedCall {
385 name: "fs_read".into(),
386 input: json!({}),
387 output: String::new(),
388 is_error: false,
389 },
390 ];
391 let replayed = vec![
392 trace("shell", json!({})),
393 trace("shell", json!({})),
394 trace("shell", json!({})),
395 ];
396
397 let d = diff(&recorded, &replayed);
398
399 assert_eq!(d.len(), 1);
400 assert_eq!(
401 d[0],
402 Divergence::Tool {
403 index: 0,
404 expected: "fs_read".into(),
405 actual: "shell".into()
406 }
407 );
408 assert!(d[0].is_structural());
409 }
410
411 #[test]
412 fn the_same_tool_with_different_arguments_is_reported_but_not_structural() {
413 let recorded = vec![RecordedCall {
414 name: "fs_read".into(),
415 input: json!({"path": "a.md"}),
416 output: String::new(),
417 is_error: false,
418 }];
419 let replayed = vec![trace("fs_read", json!({"path": "./a.md"}))];
420
421 let d = diff(&recorded, &replayed);
422
423 assert_eq!(d.len(), 1);
424 assert!(!d[0].is_structural());
427 }
428
429 #[test]
430 fn running_long_and_stopping_early_are_different_findings() {
431 let one = |name: &str| RecordedCall {
432 name: name.into(),
433 input: json!({}),
434 output: String::new(),
435 is_error: false,
436 };
437
438 let extra = diff(
439 &[one("fs_read")],
440 &[trace("fs_read", json!({})), trace("shell", json!({}))],
441 );
442 assert_eq!(
443 extra,
444 vec![Divergence::Extra {
445 index: 1,
446 actual: "shell".into()
447 }]
448 );
449
450 let missing = diff(
451 &[one("fs_read"), one("shell")],
452 &[trace("fs_read", json!({}))],
453 );
454 assert_eq!(
455 missing,
456 vec![Divergence::Missing {
457 index: 1,
458 expected: "shell".into()
459 }]
460 );
461 }
462
463 #[test]
464 fn order_is_part_of_the_trajectory_not_an_incidental_detail() {
465 let one = |p: &str| RecordedCall {
468 name: "fs_read".into(),
469 input: json!({"path": p}),
470 output: String::new(),
471 is_error: false,
472 };
473 let d = diff(
474 &[one("a.md"), one("b.md")],
475 &[
476 trace("fs_read", json!({"path": "b.md"})),
477 trace("fs_read", json!({"path": "a.md"})),
478 ],
479 );
480
481 assert_eq!(d.len(), 2, "a reordering went unreported");
482 }
483
484 #[test]
485 fn whitespace_in_arguments_does_not_count_as_a_change() {
486 let recorded = vec![RecordedCall {
487 name: "shell".into(),
488 input: json!({"command": "ls -la"}),
489 output: String::new(),
490 is_error: false,
491 }];
492 let replayed = vec![trace("shell", json!({"command": " ls -la "}))];
493
494 assert!(diff(&recorded, &replayed).is_empty());
495 }
496}