Skip to main content

codeswarm_core/
contract.rs

1//! Tolerant normalization helpers for external adapter state.
2//!
3//! Adapter protocols are external state: catalogs can be reordered or
4//! replaced, and capability fields may be absent or malformed.  Normalize
5//! those values at the core boundary before feeding them to the reducer.
6//! These helpers intentionally discard invalid entries rather than allowing a
7//! single bad catalog item to poison a session.
8
9use serde_json::Value;
10
11use crate::{AgentCapabilities, AgentEvent, Mode, SessionState, reduce};
12
13/// Normalize a protocol capability object. Unknown, omitted, or non-boolean
14/// fields resolve to `false`; a malformed object therefore behaves like an
15/// adapter with no optional capabilities.
16pub fn normalize_capabilities(value: &Value) -> AgentCapabilities {
17    let object = value
18        .get("agentCapabilities")
19        .and_then(Value::as_object)
20        .or_else(|| value.as_object());
21    let Some(object) = object else {
22        return AgentCapabilities::default();
23    };
24    AgentCapabilities {
25        supports_cancel: bool_field(object, &["supports_cancel", "supportsCancel", "cancel"]),
26        supports_modes: bool_field(object, &["supports_modes", "supportsModes", "modes"]),
27        supports_permissions: bool_field(
28            object,
29            &["supports_permissions", "supportsPermissions", "permissions"],
30        ),
31        supports_terminals: bool_field(
32            object,
33            &["supports_terminals", "supportsTerminals", "terminals"],
34        ),
35        supports_session_load: bool_field(
36            object,
37            &[
38                "supports_session_load",
39                "supportsSessionLoad",
40                "loadSession",
41            ],
42        ),
43    }
44}
45
46/// Normalize a mode catalog from either a bare array or an ACP-style object
47/// containing `availableModes`. Invalid entries and duplicate IDs are dropped
48/// while preserving the adapter's remaining order.
49pub fn normalize_modes(value: &Value) -> Vec<Mode> {
50    let modes = value
51        .as_array()
52        .or_else(|| value.get("availableModes").and_then(Value::as_array));
53    let Some(modes) = modes else {
54        return Vec::new();
55    };
56    let mut normalized = Vec::with_capacity(modes.len());
57    for value in modes {
58        let Some(object) = value.as_object() else {
59            continue;
60        };
61        let Some(id) = object.get("id").and_then(Value::as_str) else {
62            continue;
63        };
64        let id = id.trim();
65        if id.is_empty() || normalized.iter().any(|mode: &Mode| mode.id == id) {
66            continue;
67        }
68        let label = object
69            .get("label")
70            .or_else(|| object.get("name"))
71            .and_then(Value::as_str)
72            .map(str::trim)
73            .filter(|label| !label.is_empty())
74            .unwrap_or(id);
75        normalized.push(Mode {
76            id: id.into(),
77            label: label.into(),
78        });
79    }
80    normalized
81}
82
83/// Replay a normalized trace through the deterministic core reducer.
84pub fn replay_trace(roster_size: usize, trace: &[AgentEvent]) -> SessionState {
85    let mut state = SessionState::new(roster_size);
86    for event in trace {
87        reduce(&mut state, event.clone());
88    }
89    state
90}
91
92/// Compare protocol-specific traces after normalization has occurred. The
93/// event vocabulary is the compatibility boundary, so equivalent ACP/native
94/// traces must replay to equal state.
95pub fn equivalent_traces(roster_size: usize, left: &[AgentEvent], right: &[AgentEvent]) -> bool {
96    replay_trace(roster_size, left) == replay_trace(roster_size, right)
97}
98
99fn bool_field(object: &serde_json::Map<String, Value>, names: &[&str]) -> bool {
100    names
101        .iter()
102        .find_map(|name| object.get(*name))
103        .and_then(Value::as_bool)
104        .unwrap_or(false)
105}
106
107#[cfg(test)]
108mod tests {
109    use serde_json::json;
110
111    use super::{equivalent_traces, normalize_capabilities, normalize_modes, replay_trace};
112    use crate::{AgentCapabilities, AgentEvent, Mode, TerminalEvent, ToolStatus, ToolUpdate};
113
114    #[test]
115    fn omitted_or_malformed_capabilities_are_safe_defaults() {
116        assert_eq!(
117            normalize_capabilities(&json!({})),
118            AgentCapabilities::default()
119        );
120        assert_eq!(
121            normalize_capabilities(&json!(null)),
122            AgentCapabilities::default()
123        );
124        assert_eq!(
125            normalize_capabilities(&json!({
126                "supportsCancel": "yes",
127                "supportsModes": true,
128                "loadSession": false,
129            })),
130            AgentCapabilities {
131                supports_modes: true,
132                ..AgentCapabilities::default()
133            }
134        );
135    }
136
137    #[test]
138    fn capability_replacement_is_a_full_external_state_update() {
139        let first = normalize_capabilities(&json!({
140            "supportsCancel": true,
141            "supportsModes": true,
142            "supportsPermissions": true,
143            "supportsTerminals": true,
144            "loadSession": true,
145        }));
146        let replacement = normalize_capabilities(&json!({"supportsCancel": false}));
147        assert!(first.supports_session_load);
148        assert!(!replacement.supports_session_load);
149        assert!(!replacement.supports_modes);
150    }
151
152    #[test]
153    fn reordered_and_replaced_mode_catalogs_are_tolerated() {
154        let reordered = normalize_modes(&json!({
155            "availableModes": [
156                {"id": "write", "name": "Write"},
157                {"id": "plan", "label": "Plan"},
158            ]
159        }));
160        assert_eq!(
161            reordered,
162            vec![
163                Mode {
164                    id: "write".into(),
165                    label: "Write".into()
166                },
167                Mode {
168                    id: "plan".into(),
169                    label: "Plan".into()
170                },
171            ]
172        );
173        let replacement = normalize_modes(&json!([
174            {"id": "plan", "label": "Plan"},
175            {"id": "plan", "label": "duplicate"},
176            {"id": "", "label": "invalid"},
177            "malformed",
178            {"id": "safe"}
179        ]));
180        assert_eq!(
181            replacement,
182            vec![
183                Mode {
184                    id: "plan".into(),
185                    label: "Plan".into()
186                },
187                Mode {
188                    id: "safe".into(),
189                    label: "safe".into()
190                },
191            ]
192        );
193    }
194
195    #[test]
196    fn equivalent_adapter_traces_replay_to_the_same_state() {
197        let acp_trace = vec![
198            AgentEvent::Ready {
199                slot: 0,
200                capabilities: normalize_capabilities(&json!({"supportsModes": true})),
201            },
202            AgentEvent::ModesReplaced {
203                slot: 0,
204                modes: normalize_modes(&json!({
205                    "availableModes": [{"id": "plan", "name": "Plan"}]
206                })),
207                current_mode: Some("plan".into()),
208            },
209            AgentEvent::Text {
210                slot: 0,
211                text: "same normalized answer".into(),
212            },
213            AgentEvent::TurnComplete { slot: 0 },
214        ];
215        let native_trace = vec![
216            AgentEvent::Ready {
217                slot: 0,
218                capabilities: AgentCapabilities {
219                    supports_modes: true,
220                    ..AgentCapabilities::default()
221                },
222            },
223            AgentEvent::ModesReplaced {
224                slot: 0,
225                modes: vec![Mode {
226                    id: "plan".into(),
227                    label: "Plan".into(),
228                }],
229                current_mode: Some("plan".into()),
230            },
231            AgentEvent::Text {
232                slot: 0,
233                text: "same normalized answer".into(),
234            },
235            AgentEvent::TurnComplete { slot: 0 },
236        ];
237        assert!(equivalent_traces(1, &acp_trace, &native_trace));
238        assert_eq!(replay_trace(1, &acp_trace), replay_trace(1, &native_trace));
239    }
240
241    #[test]
242    fn normalized_trace_fixture_covers_tool_terminal_and_completion() {
243        let trace = vec![
244            AgentEvent::Tool {
245                slot: 0,
246                update: ToolUpdate {
247                    id: "tool-1".into(),
248                    title: "shell".into(),
249                    status: ToolStatus::Completed,
250                    detail: None,
251                },
252            },
253            AgentEvent::Terminal {
254                slot: 0,
255                event: TerminalEvent::Output {
256                    id: "term-1".into(),
257                    text: "ok".into(),
258                },
259            },
260            AgentEvent::TurnComplete { slot: 0 },
261        ];
262        let replayed = replay_trace(1, &trace);
263        assert_eq!(replayed.active_slot, None);
264        assert!(equivalent_traces(1, &trace, &trace));
265    }
266}