Skip to main content

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