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};
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/// Remote host discovery, advertised on the initialize response only.
19#[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/// Parameters for `_aether/session_usage` notifications.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
41#[notification(method = "_aether/session_usage")]
42pub struct SessionUsageParams {
43    pub usage: llm::SessionUsageEvent,
44}
45
46/// Parameters for `_aether/context_cleared` notifications.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
48#[notification(method = "_aether/context_cleared")]
49pub struct ContextClearedParams {}
50
51/// Parameters for `_aether/auth_methods_updated` notifications.
52#[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/// Parameters for the `_aether/prompt_search` request.
59#[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/// Response for the `_aether/prompt_search` request.
69#[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/// A single prompt-history search hit.
78///
79/// `match_start` and `match_end` are UTF-8 byte offsets into `prompt` and are
80/// guaranteed to fall on char boundaries.
81#[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/// Parameters for the `_aether/workspace_list` request.
128#[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/// Response for the `_aether/workspace_list` request: every managed workspace
136/// originating from the same git repository as the session's working directory.
137#[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/// Parameters for the `_aether/workspace_move` request.
151#[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/// Server→client MCP extension notifications (relay → wisp).
262#[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/// Client→server MCP extension requests (wisp → relay).
269#[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/// Parameters for `_aether/sub_agent_progress` notifications.
276///
277/// This is the wire format sent from the ACP server (`aether-cli`) to clients like `wisp`.
278#[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/// Subset of agent message variants relevant for sub-agent status display.
288///
289/// The ACP server (`aether-cli`) converts `AgentEvent` to this type before
290/// serializing, so the wire format only contains these known variants.
291#[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}