Skip to main content

acp_utils/
notifications.rs

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