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