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::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/// Parameters for `_aether/session_usage` notifications.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
16#[notification(method = "_aether/session_usage")]
17pub struct SessionUsageParams {
18    pub usage: llm::SessionUsageEvent,
19}
20
21/// Parameters for `_aether/context_compaction` notifications.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
23#[notification(method = "_aether/context_compaction")]
24pub struct ContextCompactionParams {
25    pub active: bool,
26}
27
28/// Parameters for `_aether/context_cleared` notifications.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
30#[notification(method = "_aether/context_cleared")]
31pub struct ContextClearedParams {}
32
33/// Parameters for `_aether/auth_methods_updated` notifications.
34#[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/// Parameters for the `_aether/prompt_search` request.
41#[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/// Response for the `_aether/prompt_search` request.
51#[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/// A single prompt-history search hit.
60///
61/// `match_start` and `match_end` are UTF-8 byte offsets into `prompt` and are
62/// guaranteed to fall on char boundaries.
63#[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/// Parameters for the `_aether/workspace_list` request.
110#[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/// Response for the `_aether/workspace_list` request: every managed workspace
118/// originating from the same git repository as the session's working directory.
119#[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/// Parameters for the `_aether/workspace_move` request.
133#[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/// Server→client MCP extension notifications (relay → wisp).
217#[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/// Client→server MCP extension requests (wisp → relay).
224#[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/// Parameters for `_aether/sub_agent_progress` notifications.
231///
232/// This is the wire format sent from the ACP server (`aether-cli`) to clients like `wisp`.
233#[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/// Subset of agent message variants relevant for sub-agent status display.
243///
244/// The ACP server (`aether-cli`) converts `AgentEvent` to this type before
245/// serializing, so the wire format only contains these known variants.
246#[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}