Skip to main content

mecha_core/
replay.rs

1//! Re-running a recorded session and diffing what changed.
2//!
3//! The cheap, useful form of this is not "run it again and see": it is to
4//! replay the *recorded tool results* and compare only the model's choices.
5//! That turns every real session into a regression case for free, costs one
6//! model call per turn and no side effects, and — the part that matters — it
7//! isolates the variable. Replaying against live tools re-reads a filesystem
8//! and a web that have both moved, so a difference tells you nothing about the
9//! harness.
10//!
11//! This module is pure: it extracts a trajectory from a transcript and diffs
12//! two of them. Nothing here runs an agent or touches the network, for the same
13//! reason [`crate::compact`] is pure — the interesting mistakes are in deciding
14//! what counts as "the same", and those should be unit-testable.
15//!
16//! What it cannot do, and no amount of care will fix: a local server's sampler
17//! is outside this process's knowledge, and the same case measures 5/5 rather
18//! than deterministically. **Replay against a non-greedy provider is
19//! pass@k-shaped, not exact-match-shaped.** One divergent replay is a sample,
20//! not a regression.
21
22use crate::agent::ToolCallTrace;
23use crate::message::{Block, Message, Role};
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27/// One recorded tool call, paired with what it returned.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct RecordedCall {
30    pub name: String,
31    pub input: Value,
32    /// What the tool returned at record time. Replayed verbatim.
33    pub output: String,
34    pub is_error: bool,
35}
36
37/// A transcript reduced to what a replay needs.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct Trajectory {
40    /// The user's turns, in order — the input side of the replay.
41    pub turns: Vec<String>,
42    /// Every tool call, in order, with its recorded result.
43    pub calls: Vec<RecordedCall>,
44    /// The last assistant text. The weakest signal, kept for reporting only.
45    pub final_text: String,
46    /// True when the recording contains mid-run steering.
47    ///
48    /// Steering text rides in the same user message as the tool results it
49    /// accompanies, because there is no legal slot between a `tool_use` and its
50    /// result. That makes it indistinguishable from a turn once flattened, and
51    /// re-submitting it as one would change the shape of the conversation being
52    /// replayed. Flagged rather than silently dropped: a caller that replays a
53    /// steered session anyway should know the comparison is approximate.
54    pub steered: bool,
55}
56
57/// Reduce a recorded conversation to a replayable trajectory.
58///
59/// The distinction that does the work here: a user message carrying
60/// `tool_result` blocks is the harness feeding results back, *not* the user
61/// saying something. Treating those as turns would replay a conversation with
62/// twice the turns and none of the same structure.
63pub fn extract(messages: &[Message]) -> Trajectory {
64    let mut t = Trajectory::default();
65    // tool_use blocks awaiting their results, in the order they were issued.
66    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                    // A genuine user turn.
96                    if !text.trim().is_empty() {
97                        t.turns.push(text);
98                    }
99                    continue;
100                }
101
102                // Results coming back. Text alongside them is steering.
103                if !text.trim().is_empty() {
104                    t.steered = true;
105                }
106                for (id, output, is_error) in results {
107                    // Match by id rather than position: calls are issued in
108                    // parallel and nothing promises the results come back in
109                    // the order they were asked for.
110                    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/// How a replayed run departed from its recording.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(tag = "kind", rename_all = "snake_case")]
130pub enum Divergence {
131    /// A different tool entirely. The strongest signal there is.
132    Tool {
133        index: usize,
134        expected: String,
135        actual: String,
136    },
137    /// The right tool, different arguments. Worth reporting separately: a
138    /// model that reads the same file by a different path spelling has not
139    /// regressed, and grading it as though it had makes replay useless inside a
140    /// week.
141    Arguments {
142        index: usize,
143        tool: String,
144        expected: Value,
145        actual: Value,
146    },
147    /// The replay kept going after the recording ran out.
148    Extra { index: usize, actual: String },
149    /// The replay stopped early.
150    Missing { index: usize, expected: String },
151}
152
153impl Divergence {
154    /// Where in the call sequence it happened.
155    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    /// Whether this changes *what the model did* rather than how it spelled it.
165    ///
166    /// Only argument differences are ever cosmetic, and only the caller knows
167    /// whether they are — hence a predicate rather than a filter applied here.
168    pub fn is_structural(&self) -> bool {
169        !matches!(self, Divergence::Arguments { .. })
170    }
171}
172
173/// [`diff`], for a replay that joined the recording mid-stream: `recorded` is
174/// the tail from `base` onward, and every reported index comes back shifted
175/// into the *full* recording's coordinates. A probe point is located in those
176/// coordinates, so a branched replay's divergences must land there too, or
177/// "before the steer point" compares indices from two different countings.
178pub 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
195/// Compare a replayed trace against its recording, call by call.
196///
197/// Positional rather than set-based on purpose: the order tools are called in
198/// *is* the trajectory. A run that reads the same four files in a different
199/// order made different decisions, and a set comparison would call them equal.
200pub 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            // Once the tools differ, every later comparison is between two
211            // sequences that already parted company. Report the first and stop
212            // rather than emitting a cascade that all has one cause.
213            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
241/// Arguments match when their JSON is equal after normalising whitespace in
242/// strings.
243///
244/// Deliberately not fuzzy beyond that. Path normalisation is tempting —
245/// `./a.md` and `a.md` name the same file — but it is tool-specific knowledge,
246/// and the loop is not supposed to know what any particular tool means. A
247/// caller that wants it can filter on [`Divergence::is_structural`].
248fn 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        // One turn, not two: the tool results are the harness talking, not the
307        // user, and counting them would replay a conversation twice as long.
308        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        // Parallel calls, results back in the other order — which is allowed,
331        // and which position-matching would silently mis-pair, attaching each
332        // call to the other's output.
333        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        // The text rides with the results because there is no legal slot
358        // between a `tool_use` and its result. Replaying it as a turn would
359        // change the shape of the conversation under test.
360        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        // Everything after the fork is two sequences that already parted
392        // company; reporting all of it buries the one fact that matters.
393        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        // A caller deciding what counts as a regression needs these separable:
447        // the same file by another spelling is not a behaviour change.
448        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        // A set comparison would call these equal. They are not: reading the
488        // files in a different order is a different set of decisions.
489        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    /// A branched replay diffs the recording's tail, but a probe point lives
507    /// in the full recording's coordinates — reported indices must land there.
508    #[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        // And a zero base is exactly `diff`.
538        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}