1use serde_json::Value;
10
11use crate::{AgentCapabilities, AgentEvent, Mode, SessionState, reduce};
12
13pub 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
47pub 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
84pub 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
93pub 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}