1use std::path::PathBuf;
4
5use agent_client_protocol::schema::v1::{AuthMethod, Meta};
6use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
7pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9
10pub use mcp_utils::status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry};
11
12pub const AETHER_META_NAMESPACE: &str = "contextbridge/aether";
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
16#[notification(method = "_aether/session_usage")]
17pub struct SessionUsageParams {
18 pub usage: llm::SessionUsageEvent,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
23#[notification(method = "_aether/context_compaction")]
24pub struct ContextCompactionParams {
25 pub active: bool,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
30#[notification(method = "_aether/context_cleared")]
31pub struct ContextClearedParams {}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
35#[notification(method = "_aether/auth_methods_updated")]
36pub struct AuthMethodsUpdatedParams {
37 pub auth_methods: Vec<AuthMethod>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
42#[request(method = "_aether/prompt_search", response = PromptSearchResponse)]
43#[serde(rename_all = "camelCase")]
44pub struct PromptSearchParams {
45 pub query: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub limit: Option<usize>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
52#[serde(rename_all = "camelCase")]
53pub struct PromptSearchResponse {
54 pub query: String,
55 pub results: Vec<PromptSearchResult>,
56 pub truncated: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct PromptSearchResult {
66 pub session_id: String,
67 pub cwd: PathBuf,
68 pub session_created_at: String,
69 pub prompt: String,
70 pub match_start: usize,
71 pub match_end: usize,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
75#[request(method = "_aether/session_preview", response = SessionPreviewResponse)]
76#[serde(rename_all = "camelCase")]
77pub struct SessionPreviewParams {
78 pub session_id: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
82#[serde(rename_all = "camelCase")]
83pub struct SessionPreviewResponse {
84 pub session_id: String,
85 pub cwd: PathBuf,
86 pub created_at: String,
87 pub model: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub selected_mode: Option<String>,
90 pub transcript: Vec<SessionPreviewTurn>,
91 pub tool_call_count: usize,
92 pub truncated: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "camelCase")]
97pub struct SessionPreviewTurn {
98 pub role: SessionPreviewRole,
99 pub text: String,
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "camelCase")]
104pub enum SessionPreviewRole {
105 User,
106 Assistant,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
111#[request(method = "_aether/workspace_list", response = WorkspaceListResponse)]
112#[serde(rename_all = "camelCase")]
113pub struct WorkspaceListParams {
114 pub session_id: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
120#[serde(rename_all = "camelCase")]
121pub struct WorkspaceListResponse {
122 pub workspaces: Vec<WorkspaceEntry>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "camelCase")]
127pub struct WorkspaceEntry {
128 pub path: PathBuf,
129 pub is_current: bool,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
134#[request(method = "_aether/workspace_move", response = WorkspaceMoveResponse)]
135#[serde(rename_all = "camelCase")]
136pub struct WorkspaceMoveParams {
137 pub session_id: String,
138 pub target: WorkspaceMoveTarget,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(tag = "kind", rename_all = "camelCase")]
143pub enum WorkspaceMoveTarget {
144 Existing { path: PathBuf },
145 New { name: String },
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
149#[serde(rename_all = "camelCase")]
150pub struct WorkspaceMoveResponse {
151 pub new_cwd: PathBuf,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
155#[serde(rename_all = "camelCase")]
156pub struct SessionDisplayMeta {
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub model: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub selected_mode: Option<String>,
161}
162
163impl SessionDisplayMeta {
164 #[must_use]
165 pub fn new(model: impl Into<String>, selected_mode: Option<String>) -> Self {
166 Self { model: Some(model.into()), selected_mode }
167 }
168
169 #[must_use]
170 pub fn to_meta(&self) -> Meta {
171 to_aether_meta(self)
172 }
173
174 #[must_use]
175 pub fn from_meta(meta: Option<&Meta>) -> Self {
176 from_aether_meta(meta)
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
181#[serde(rename_all = "camelCase")]
182pub struct AetherCapabilities {
183 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
184 pub prompt_search: bool,
185 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
186 pub session_preview: bool,
187 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188 pub workspace_move: bool,
189}
190
191impl AetherCapabilities {
192 #[must_use]
193 pub fn to_meta(self) -> Meta {
194 to_aether_meta(&self)
195 }
196
197 #[must_use]
198 pub fn from_meta(meta: Option<&Meta>) -> Self {
199 from_aether_meta(meta)
200 }
201}
202
203fn to_aether_meta<T: Serialize>(value: &T) -> Meta {
204 let mut meta = Meta::new();
205 meta.insert(AETHER_META_NAMESPACE.to_string(), serde_json::json!(value));
206 meta
207}
208
209fn from_aether_meta<T: DeserializeOwned + Default>(meta: Option<&Meta>) -> T {
210 meta.and_then(|m| m.get(AETHER_META_NAMESPACE))
211 .cloned()
212 .and_then(|value| serde_json::from_value(value).ok())
213 .unwrap_or_default()
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
218#[notification(method = "_aether/mcp_event")]
219pub enum McpNotification {
220 ServerStatus { servers: Vec<McpServerStatusEntry> },
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
225#[notification(method = "_aether/mcp_request")]
226pub enum McpRequest {
227 Authenticate { session_id: String, server_name: String },
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
234#[notification(method = "_aether/sub_agent_progress")]
235pub struct SubAgentProgressParams {
236 pub parent_tool_id: String,
237 pub task_id: String,
238 pub agent_name: String,
239 pub event: SubAgentEvent,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
247pub enum SubAgentEvent {
248 ToolCall { request: SubAgentToolRequest },
249 ToolCallUpdate { update: SubAgentToolCallUpdate },
250 ToolResult { result: SubAgentToolResult },
251 ToolError { error: SubAgentToolError },
252 Done,
253 Other,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SubAgentToolRequest {
258 pub id: String,
259 pub name: String,
260 pub arguments: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct SubAgentToolCallUpdate {
265 pub id: String,
266 pub chunk: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct SubAgentToolResult {
271 pub id: String,
272 pub name: String,
273 pub result_meta: Option<ToolResultMeta>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct SubAgentToolError {
278 pub id: String,
279 pub name: String,
280}
281
282#[cfg(test)]
283mod tests {
284 use agent_client_protocol::JsonRpcMessage;
285 use agent_client_protocol::schema::v1::AuthMethodAgent;
286
287 use super::*;
288
289 #[test]
290 fn wire_method_names_are_prefixed() {
291 assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
292 assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
293 assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
294 assert_eq!(
295 McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
296 "_aether/mcp_request"
297 );
298 assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
299 assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
300 assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
301 let move_params =
302 WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
303 assert_eq!(move_params.method(), "_aether/workspace_move");
304 }
305
306 #[test]
307 fn context_compaction_params_roundtrip() {
308 for active in [true, false] {
309 let params = ContextCompactionParams { active };
310 let untyped = params.to_untyped_message().expect("serializable");
311 assert_eq!(untyped.method(), "_aether/context_compaction");
312 let parsed = ContextCompactionParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
313 assert_eq!(parsed, params);
314 }
315 }
316
317 #[test]
318 fn context_cleared_params_roundtrip() {
319 let params = ContextClearedParams::default();
320 let untyped = params.to_untyped_message().expect("serializable");
321 assert_eq!(untyped.method(), "_aether/context_cleared");
322 let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
323 assert_eq!(parsed, params);
324 }
325
326 #[test]
327 fn auth_methods_updated_roundtrip() {
328 let params = AuthMethodsUpdatedParams {
329 auth_methods: vec![
330 AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
331 AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
332 ],
333 };
334
335 let untyped = params.to_untyped_message().expect("serializable");
336 assert_eq!(untyped.method(), "_aether/auth_methods_updated");
337 let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
338 assert_eq!(parsed, params);
339 }
340
341 #[test]
342 fn mcp_request_authenticate_roundtrip() {
343 let msg = McpRequest::Authenticate {
344 session_id: "session-0".to_string(),
345 server_name: "my oauth server".to_string(),
346 };
347
348 let untyped = msg.to_untyped_message().expect("serializable");
349 assert_eq!(untyped.method(), "_aether/mcp_request");
350 let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
351 assert_eq!(parsed, msg);
352 }
353
354 #[test]
355 fn mcp_notification_server_status_roundtrip() {
356 let msg = McpNotification::ServerStatus {
357 servers: vec![
358 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
359 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
360 .with_auth_capability(McpServerAuthCapability::OAuth),
361 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
362 ],
363 };
364
365 let untyped = msg.to_untyped_message().expect("serializable");
366 assert_eq!(untyped.method(), "_aether/mcp_event");
367 let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
368 assert_eq!(parsed, msg);
369 }
370
371 #[test]
372 fn sub_agent_progress_params_roundtrip() {
373 let params = SubAgentProgressParams {
374 parent_tool_id: "call_123".to_string(),
375 task_id: "task_abc".to_string(),
376 agent_name: "explorer".to_string(),
377 event: SubAgentEvent::Done,
378 };
379
380 let untyped = params.to_untyped_message().expect("serializable");
381 assert_eq!(untyped.method(), "_aether/sub_agent_progress");
382 }
383
384 #[test]
385 fn mcp_server_status_entry_serde_roundtrip() {
386 let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
387 .with_auth_capability(McpServerAuthCapability::OAuth);
388
389 let json = serde_json::to_string(&entry).unwrap();
390 assert!(json.contains("\"auth_capability\":\"OAuth\""));
391 assert!(json.contains("\"deferTools\":false"));
392 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
393 assert_eq!(parsed, entry);
394 assert!(!parsed.deferred_tools);
395 assert!(parsed.can_authenticate());
396 }
397
398 #[test]
399 fn mcp_server_status_entry_deferred_tools_serde_roundtrip() {
400 let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
401 .with_auth_capability(McpServerAuthCapability::OAuth)
402 .with_deferred_tools(true);
403
404 let json = serde_json::to_string(&entry).unwrap();
405 assert!(json.contains("\"deferTools\":true"));
406 let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
407 assert_eq!(parsed, entry);
408 }
409
410 #[test]
411 fn deserialize_tool_call_event() {
412 let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
413 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
414 assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
415 }
416
417 #[test]
418 fn deserialize_tool_call_update_event() {
419 let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
420 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
421 assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
422 }
423
424 #[test]
425 fn deserialize_tool_result_event() {
426 let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
427 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
428 match event {
429 SubAgentEvent::ToolResult { result } => {
430 let result_meta = result.result_meta.expect("expected result_meta");
431 assert_eq!(result_meta.display.title, "Grep");
432 }
433 other => panic!("Expected ToolResult, got {other:?}"),
434 }
435 }
436
437 #[test]
438 fn deserialize_tool_error_event() {
439 let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
440 let event: SubAgentEvent = serde_json::from_str(json).unwrap();
441 assert!(matches!(event, SubAgentEvent::ToolError { .. }));
442 }
443
444 #[test]
445 fn deserialize_done_event() {
446 let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
447 assert!(matches!(event, SubAgentEvent::Done));
448 }
449
450 #[test]
451 fn deserialize_other_variant() {
452 let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
453 assert!(matches!(event, SubAgentEvent::Other));
454 }
455
456 #[test]
457 fn tool_result_meta_map_roundtrip() {
458 let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
459 let map = meta.clone().into_map();
460 let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
461 assert_eq!(parsed, meta);
462 }
463}