Skip to main content

codeswarm_core/
trace.rs

1//! Deterministic scripted traces for adapter cutover and dogfooding.
2//!
3//! Protocol adapters are compared only after they have emitted the shared
4//! [`AgentEvent`] vocabulary.  This keeps trace-corpus checks independent of
5//! ACP/native process details and makes failures replayable in CI or locally.
6
7use serde::{Deserialize, Serialize};
8
9use crate::{AgentEvent, SessionState, contract::replay_trace};
10
11/// A named, serializable normalized trace fixture.
12#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
13pub struct ScriptedTrace {
14    pub name: String,
15    pub roster_size: usize,
16    pub events: Vec<AgentEvent>,
17}
18
19impl ScriptedTrace {
20    pub fn new(name: impl Into<String>, roster_size: usize, events: Vec<AgentEvent>) -> Self {
21        Self {
22            name: name.into(),
23            roster_size,
24            events,
25        }
26    }
27
28    pub fn replay(&self) -> SessionState {
29        replay_trace(self.roster_size, &self.events)
30    }
31}
32
33/// The first event mismatch, if two normalized traces are not identical.
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct TraceDifference {
36    pub index: usize,
37    pub left: Option<AgentEvent>,
38    pub right: Option<AgentEvent>,
39}
40
41/// Comparison result includes both event and replay-state equality. A pair of
42/// traces can reach equal state while still differing in protocol behavior;
43/// that distinction is useful during adapter cutover.
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45pub struct TraceComparison {
46    pub events_equal: bool,
47    pub final_state_equal: bool,
48    pub first_difference: Option<TraceDifference>,
49}
50
51impl TraceComparison {
52    pub fn equivalent(&self) -> bool {
53        self.events_equal && self.final_state_equal
54    }
55}
56
57pub fn compare_traces(left: &ScriptedTrace, right: &ScriptedTrace) -> TraceComparison {
58    let first_difference = left
59        .events
60        .iter()
61        .zip(&right.events)
62        .position(|(left, right)| left != right)
63        .map(|index| TraceDifference {
64            index,
65            left: left.events.get(index).cloned(),
66            right: right.events.get(index).cloned(),
67        })
68        .or_else(|| {
69            (left.events.len() != right.events.len()).then(|| {
70                let index = left.events.len().min(right.events.len());
71                TraceDifference {
72                    index,
73                    left: left.events.get(index).cloned(),
74                    right: right.events.get(index).cloned(),
75                }
76            })
77        });
78    TraceComparison {
79        events_equal: first_difference.is_none(),
80        final_state_equal: left.roster_size == right.roster_size && left.replay() == right.replay(),
81        first_difference,
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use serde_json::json;
88
89    use super::{ScriptedTrace, compare_traces};
90    use crate::{AgentCapabilities, AgentEvent, Mode, TerminalEvent, ToolStatus, ToolUpdate};
91
92    fn shared_events() -> Vec<AgentEvent> {
93        vec![
94            AgentEvent::Ready {
95                slot: 0,
96                capabilities: AgentCapabilities {
97                    supports_cancel: true,
98                    supports_modes: true,
99                    supports_terminals: true,
100                    ..AgentCapabilities::default()
101                },
102            },
103            AgentEvent::ModesReplaced {
104                slot: 0,
105                modes: vec![Mode {
106                    id: "plan".into(),
107                    label: "Plan".into(),
108                }],
109                current_mode: Some("plan".into()),
110            },
111            AgentEvent::Tool {
112                slot: 0,
113                update: ToolUpdate {
114                    id: "tool-1".into(),
115                    title: "shell".into(),
116                    status: ToolStatus::Completed,
117                    detail: Some("done".into()),
118                },
119            },
120            AgentEvent::Terminal {
121                slot: 0,
122                event: TerminalEvent::Output {
123                    id: "terminal-1".into(),
124                    text: "ok".into(),
125                },
126            },
127            AgentEvent::Text {
128                slot: 0,
129                text: "completed".into(),
130            },
131            AgentEvent::TurnComplete { slot: 0 },
132        ]
133    }
134
135    #[test]
136    fn shared_scripted_acp_and_native_traces_compare_equal() {
137        let acp = ScriptedTrace::new("acp-smoke", 1, shared_events());
138        let native = ScriptedTrace::new("native-smoke", 1, shared_events());
139        let comparison = compare_traces(&acp, &native);
140        assert!(comparison.equivalent());
141        assert!(comparison.first_difference.is_none());
142    }
143
144    #[test]
145    fn comparison_reports_first_event_difference_and_state_difference() {
146        let left = ScriptedTrace::new("left", 1, shared_events());
147        let mut right_events = shared_events();
148        right_events[4] = AgentEvent::Text {
149            slot: 0,
150            text: "different".into(),
151        };
152        let right = ScriptedTrace::new("right", 1, right_events);
153        let comparison = compare_traces(&left, &right);
154        assert!(!comparison.equivalent());
155        assert_eq!(comparison.first_difference.expect("difference").index, 4);
156        assert!(!comparison.final_state_equal);
157    }
158
159    #[test]
160    fn trace_fixture_round_trip_and_replay_are_deterministic() {
161        let fixture = ScriptedTrace::new("fixture", 1, shared_events());
162        let encoded = serde_json::to_string(&fixture).expect("serialize");
163        let decoded: ScriptedTrace = serde_json::from_str(&encoded).expect("deserialize");
164        assert_eq!(fixture, decoded);
165        let first = decoded.replay();
166        for _ in 0..10 {
167            assert_eq!(decoded.replay(), first);
168        }
169    }
170
171    #[test]
172    fn comparison_is_json_stable_for_ci_artifacts() {
173        let trace = ScriptedTrace::new("fixture", 1, shared_events());
174        let comparison = compare_traces(&trace, &trace);
175        let value = serde_json::to_value(comparison).expect("json");
176        assert_eq!(value["events_equal"], json!(true));
177        assert_eq!(value["final_state_equal"], json!(true));
178        assert_eq!(value["first_difference"], Value::Null);
179    }
180
181    use serde_json::Value;
182}