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/// Compare a replayed trace against its recording, call by call.
174///
175/// Positional rather than set-based on purpose: the order tools are called in
176/// *is* the trajectory. A run that reads the same four files in a different
177/// order made different decisions, and a set comparison would call them equal.
178pub 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            // Once the tools differ, every later comparison is between two
189            // sequences that already parted company. Report the first and stop
190            // rather than emitting a cascade that all has one cause.
191            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
219/// Arguments match when their JSON is equal after normalising whitespace in
220/// strings.
221///
222/// Deliberately not fuzzy beyond that. Path normalisation is tempting —
223/// `./a.md` and `a.md` name the same file — but it is tool-specific knowledge,
224/// and the loop is not supposed to know what any particular tool means. A
225/// caller that wants it can filter on [`Divergence::is_structural`].
226fn 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        // One turn, not two: the tool results are the harness talking, not the
285        // user, and counting them would replay a conversation twice as long.
286        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        // Parallel calls, results back in the other order — which is allowed,
309        // and which position-matching would silently mis-pair, attaching each
310        // call to the other's output.
311        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        // The text rides with the results because there is no legal slot
336        // between a `tool_use` and its result. Replaying it as a turn would
337        // change the shape of the conversation under test.
338        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        // Everything after the fork is two sequences that already parted
370        // company; reporting all of it buries the one fact that matters.
371        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        // A caller deciding what counts as a regression needs these separable:
425        // the same file by another spelling is not a behaviour change.
426        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        // A set comparison would call these equal. They are not: reading the
466        // files in a different order is a different set of decisions.
467        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}