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 }
44}
45
46pub 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
83pub 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
92pub 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}