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