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::AuthMethod;
6use agent_client_protocol::{JsonRpcNotification, JsonRpcRequest, JsonRpcResponse};
7pub use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
8pub use rmcp::model::CreateElicitationRequestParams;
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10
11pub use mcp_utils::status::{McpServerAuthCapability, McpServerStatus, McpServerStatusEntry};
12
13pub const AETHER_META_NAMESPACE: &str = "contextbridge/aether";
14
15/// Context/token usage reported after an LLM call.
16///
17/// Per-call fields (`input_tokens`, `output_tokens`, `cache_read_tokens`,
18/// `cache_creation_tokens`, `reasoning_tokens`) come from the most recent
19/// API response. The `total_*` fields are cumulative across the agent's
20/// lifetime. The optional fields are `None` when the provider doesn't
21/// expose that dimension; this is semantically distinct from `Some(0)`.
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
23pub struct ContextUsage {
24    /// Current usage ratio (0.0 - 1.0), if context window is known.
25    pub usage_ratio: Option<f64>,
26    /// Maximum context limit, if known.
27    pub context_limit: Option<u32>,
28    /// Input tokens on the most recent API call (the current context size).
29    pub input_tokens: u32,
30    /// Output tokens on the most recent API call.
31    #[serde(default)]
32    pub output_tokens: u32,
33    /// Prompt tokens served from cache on the most recent API call.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub cache_read_tokens: Option<u32>,
36    /// Prompt tokens written to cache on the most recent API call.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub cache_creation_tokens: Option<u32>,
39    /// Reasoning tokens spent on the most recent API call.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub reasoning_tokens: Option<u32>,
42    /// Cumulative input tokens since the agent started.
43    #[serde(default)]
44    pub total_input_tokens: u64,
45    /// Cumulative output tokens since the agent started.
46    #[serde(default)]
47    pub total_output_tokens: u64,
48    /// Cumulative cache-read tokens since the agent started.
49    #[serde(default)]
50    pub total_cache_read_tokens: u64,
51    /// Cumulative cache-creation tokens since the agent started.
52    #[serde(default)]
53    pub total_cache_creation_tokens: u64,
54    /// Cumulative reasoning tokens since the agent started.
55    #[serde(default)]
56    pub total_reasoning_tokens: u64,
57}
58
59impl ContextUsage {
60    /// Sum of cumulative input + output tokens.
61    pub fn total_tokens(&self) -> u64 {
62        self.total_input_tokens + self.total_output_tokens
63    }
64}
65
66/// Parameters for `_aether/context_usage` notifications.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcNotification)]
68#[notification(method = "_aether/context_usage")]
69pub struct ContextUsageParams {
70    #[serde(flatten)]
71    pub usage: ContextUsage,
72}
73
74/// Parameters for `_aether/context_compaction` notifications.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
76#[notification(method = "_aether/context_compaction")]
77pub struct ContextCompactionParams {
78    pub active: bool,
79}
80
81/// Parameters for `_aether/context_cleared` notifications.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, JsonRpcNotification)]
83#[notification(method = "_aether/context_cleared")]
84pub struct ContextClearedParams {}
85
86/// Parameters for `_aether/auth_methods_updated` notifications.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
88#[notification(method = "_aether/auth_methods_updated")]
89pub struct AuthMethodsUpdatedParams {
90    pub auth_methods: Vec<AuthMethod>,
91}
92
93/// Request parameters for the `_aether/elicitation` ext method.
94///
95/// Carries the full RMCP elicitation request plus the originating server name
96/// so the client can distinguish form vs URL mode and display which server is
97/// requesting.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcRequest)]
99#[request(method = "_aether/elicitation", response = ElicitationResponse)]
100pub struct ElicitationParams {
101    pub server_name: String,
102    pub request: CreateElicitationRequestParams,
103}
104
105pub use rmcp::model::ElicitationAction;
106
107/// Parameters for the `_aether/prompt_search` request.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
109#[request(method = "_aether/prompt_search", response = PromptSearchResponse)]
110#[serde(rename_all = "camelCase")]
111pub struct PromptSearchParams {
112    pub query: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub limit: Option<usize>,
115}
116
117/// Response for the `_aether/prompt_search` request.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
119#[serde(rename_all = "camelCase")]
120pub struct PromptSearchResponse {
121    pub query: String,
122    pub results: Vec<PromptSearchResult>,
123    pub truncated: bool,
124}
125
126/// A single prompt-history search hit.
127///
128/// `match_start` and `match_end` are UTF-8 byte offsets into `prompt` and are
129/// guaranteed to fall on char boundaries.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "camelCase")]
132pub struct PromptSearchResult {
133    pub session_id: String,
134    pub cwd: PathBuf,
135    pub session_created_at: String,
136    pub prompt: String,
137    pub match_start: usize,
138    pub match_end: usize,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
142#[request(method = "_aether/session_preview", response = SessionPreviewResponse)]
143#[serde(rename_all = "camelCase")]
144pub struct SessionPreviewParams {
145    pub session_id: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
149#[serde(rename_all = "camelCase")]
150pub struct SessionPreviewResponse {
151    pub session_id: String,
152    pub cwd: PathBuf,
153    pub created_at: String,
154    pub model: String,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub selected_mode: Option<String>,
157    pub transcript: Vec<SessionPreviewTurn>,
158    pub tool_call_count: usize,
159    pub truncated: bool,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase")]
164pub struct SessionPreviewTurn {
165    pub role: SessionPreviewRole,
166    pub text: String,
167}
168
169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "camelCase")]
171pub enum SessionPreviewRole {
172    User,
173    Assistant,
174}
175
176/// Parameters for the `_aether/workspace_list` request.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
178#[request(method = "_aether/workspace_list", response = WorkspaceListResponse)]
179#[serde(rename_all = "camelCase")]
180pub struct WorkspaceListParams {
181    pub session_id: String,
182}
183
184/// Response for the `_aether/workspace_list` request: every managed workspace
185/// originating from the same git repository as the session's working directory.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
187#[serde(rename_all = "camelCase")]
188pub struct WorkspaceListResponse {
189    pub workspaces: Vec<WorkspaceEntry>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "camelCase")]
194pub struct WorkspaceEntry {
195    pub path: PathBuf,
196    pub is_current: bool,
197}
198
199/// Parameters for the `_aether/workspace_move` request.
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcRequest)]
201#[request(method = "_aether/workspace_move", response = WorkspaceMoveResponse)]
202#[serde(rename_all = "camelCase")]
203pub struct WorkspaceMoveParams {
204    pub session_id: String,
205    pub target: WorkspaceMoveTarget,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209#[serde(tag = "kind", rename_all = "camelCase")]
210pub enum WorkspaceMoveTarget {
211    Existing { path: PathBuf },
212    New { name: String },
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcResponse)]
216#[serde(rename_all = "camelCase")]
217pub struct WorkspaceMoveResponse {
218    pub new_cwd: PathBuf,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
222#[serde(rename_all = "camelCase")]
223pub struct SessionDisplayMeta {
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub model: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub selected_mode: Option<String>,
228}
229
230impl SessionDisplayMeta {
231    #[must_use]
232    pub fn new(model: impl Into<String>, selected_mode: Option<String>) -> Self {
233        Self { model: Some(model.into()), selected_mode }
234    }
235
236    #[must_use]
237    pub fn to_meta(&self) -> agent_client_protocol::schema::Meta {
238        to_aether_meta(self)
239    }
240
241    #[must_use]
242    pub fn from_meta(meta: Option<&agent_client_protocol::schema::Meta>) -> Self {
243        from_aether_meta(meta)
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
248#[serde(rename_all = "camelCase")]
249pub struct AetherCapabilities {
250    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
251    pub prompt_search: bool,
252    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
253    pub session_preview: bool,
254    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255    pub workspace_move: bool,
256}
257
258impl AetherCapabilities {
259    #[must_use]
260    pub fn to_meta(self) -> agent_client_protocol::schema::Meta {
261        to_aether_meta(&self)
262    }
263
264    #[must_use]
265    pub fn from_meta(meta: Option<&agent_client_protocol::schema::Meta>) -> Self {
266        from_aether_meta(meta)
267    }
268}
269
270fn to_aether_meta<T: Serialize>(value: &T) -> agent_client_protocol::schema::Meta {
271    let mut meta = agent_client_protocol::schema::Meta::new();
272    meta.insert(AETHER_META_NAMESPACE.to_string(), serde_json::json!(value));
273    meta
274}
275
276fn from_aether_meta<T: DeserializeOwned + Default>(meta: Option<&agent_client_protocol::schema::Meta>) -> T {
277    meta.and_then(|m| m.get(AETHER_META_NAMESPACE))
278        .cloned()
279        .and_then(|value| serde_json::from_value(value).ok())
280        .unwrap_or_default()
281}
282
283/// Response returned from the client for an elicitation request.
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonRpcResponse)]
285pub struct ElicitationResponse {
286    pub action: ElicitationAction,
287    /// Structured form data when action is "accept".
288    pub content: Option<serde_json::Value>,
289}
290
291pub use mcp_utils::client::UrlElicitationCompleteParams;
292
293/// Server→client MCP extension notifications (relay → wisp).
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
295#[notification(method = "_aether/mcp_event")]
296pub enum McpNotification {
297    ServerStatus { servers: Vec<McpServerStatusEntry> },
298    UrlElicitationComplete(UrlElicitationCompleteParams),
299}
300
301/// Client→server MCP extension requests (wisp → relay).
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
303#[notification(method = "_aether/mcp_request")]
304pub enum McpRequest {
305    Authenticate { session_id: String, server_name: String },
306}
307
308/// Parameters for `_aether/sub_agent_progress` notifications.
309///
310/// This is the wire format sent from the ACP server (`aether-cli`) to clients like `wisp`.
311#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
312#[notification(method = "_aether/sub_agent_progress")]
313pub struct SubAgentProgressParams {
314    pub parent_tool_id: String,
315    pub task_id: String,
316    pub agent_name: String,
317    pub event: SubAgentEvent,
318}
319
320/// Subset of agent message variants relevant for sub-agent status display.
321///
322/// The ACP server (`aether-cli`) converts `AgentEvent` to this type before
323/// serializing, so the wire format only contains these known variants.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub enum SubAgentEvent {
326    ToolCall { request: SubAgentToolRequest },
327    ToolCallUpdate { update: SubAgentToolCallUpdate },
328    ToolResult { result: SubAgentToolResult },
329    ToolError { error: SubAgentToolError },
330    Done,
331    Other,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SubAgentToolRequest {
336    pub id: String,
337    pub name: String,
338    pub arguments: String,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct SubAgentToolCallUpdate {
343    pub id: String,
344    pub chunk: String,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct SubAgentToolResult {
349    pub id: String,
350    pub name: String,
351    pub result_meta: Option<ToolResultMeta>,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct SubAgentToolError {
356    pub id: String,
357    pub name: String,
358}
359
360#[cfg(test)]
361mod tests {
362    use agent_client_protocol::JsonRpcMessage;
363    use agent_client_protocol::schema::AuthMethodAgent;
364
365    use super::*;
366
367    #[test]
368    fn wire_method_names_are_prefixed() {
369        assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
370        assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
371        assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
372        assert_eq!(
373            McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
374            "_aether/mcp_request"
375        );
376        assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
377        assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
378        assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
379        let move_params =
380            WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
381        assert_eq!(move_params.method(), "_aether/workspace_move");
382    }
383
384    #[test]
385    fn context_usage_params_roundtrip() {
386        let params = ContextUsageParams {
387            usage: ContextUsage {
388                usage_ratio: Some(0.75),
389                context_limit: Some(100_000),
390                input_tokens: 75_000,
391                output_tokens: 1_200,
392                cache_read_tokens: Some(40_000),
393                cache_creation_tokens: Some(2_000),
394                reasoning_tokens: Some(500),
395                total_input_tokens: 200_000,
396                total_output_tokens: 8_000,
397                total_cache_read_tokens: 90_000,
398                total_cache_creation_tokens: 5_000,
399                total_reasoning_tokens: 1_500,
400            },
401        };
402
403        let untyped = params.to_untyped_message().expect("serializable");
404        assert_eq!(untyped.method(), "_aether/context_usage");
405        let parsed = ContextUsageParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
406        assert_eq!(parsed, params);
407    }
408
409    #[test]
410    fn context_usage_params_omits_unset_optional_token_fields() {
411        let params = ContextUsageParams {
412            usage: ContextUsage {
413                usage_ratio: Some(0.1),
414                context_limit: Some(1_000),
415                input_tokens: 100,
416                ..ContextUsage::default()
417            },
418        };
419
420        let raw = serde_json::to_string(&params).unwrap();
421        assert!(!raw.contains("\"cache_read_tokens\""));
422        assert!(!raw.contains("\"cache_creation_tokens\""));
423        assert!(!raw.contains("\"reasoning_tokens\""));
424    }
425
426    #[test]
427    fn context_compaction_params_roundtrip() {
428        for active in [true, false] {
429            let params = ContextCompactionParams { active };
430            let untyped = params.to_untyped_message().expect("serializable");
431            assert_eq!(untyped.method(), "_aether/context_compaction");
432            let parsed = ContextCompactionParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
433            assert_eq!(parsed, params);
434        }
435    }
436
437    #[test]
438    fn context_cleared_params_roundtrip() {
439        let params = ContextClearedParams::default();
440        let untyped = params.to_untyped_message().expect("serializable");
441        assert_eq!(untyped.method(), "_aether/context_cleared");
442        let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
443        assert_eq!(parsed, params);
444    }
445
446    #[test]
447    fn auth_methods_updated_roundtrip() {
448        let params = AuthMethodsUpdatedParams {
449            auth_methods: vec![
450                AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
451                AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
452            ],
453        };
454
455        let untyped = params.to_untyped_message().expect("serializable");
456        assert_eq!(untyped.method(), "_aether/auth_methods_updated");
457        let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
458        assert_eq!(parsed, params);
459    }
460
461    #[test]
462    fn mcp_request_authenticate_roundtrip() {
463        let msg = McpRequest::Authenticate {
464            session_id: "session-0".to_string(),
465            server_name: "my oauth server".to_string(),
466        };
467
468        let untyped = msg.to_untyped_message().expect("serializable");
469        assert_eq!(untyped.method(), "_aether/mcp_request");
470        let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
471        assert_eq!(parsed, msg);
472    }
473
474    #[test]
475    fn mcp_notification_server_status_roundtrip() {
476        let msg = McpNotification::ServerStatus {
477            servers: vec![
478                McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
479                McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
480                    .with_auth_capability(McpServerAuthCapability::OAuth),
481                McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
482            ],
483        };
484
485        let untyped = msg.to_untyped_message().expect("serializable");
486        assert_eq!(untyped.method(), "_aether/mcp_event");
487        let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
488        assert_eq!(parsed, msg);
489    }
490
491    #[test]
492    fn mcp_notification_url_elicitation_complete_roundtrip() {
493        let msg = McpNotification::UrlElicitationComplete(UrlElicitationCompleteParams {
494            server_name: "github".to_string(),
495            elicitation_id: "el-456".to_string(),
496        });
497
498        let untyped = msg.to_untyped_message().expect("serializable");
499        let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
500        assert_eq!(parsed, msg);
501    }
502
503    #[test]
504    fn sub_agent_progress_params_roundtrip() {
505        let params = SubAgentProgressParams {
506            parent_tool_id: "call_123".to_string(),
507            task_id: "task_abc".to_string(),
508            agent_name: "explorer".to_string(),
509            event: SubAgentEvent::Done,
510        };
511
512        let untyped = params.to_untyped_message().expect("serializable");
513        assert_eq!(untyped.method(), "_aether/sub_agent_progress");
514    }
515
516    #[test]
517    fn elicitation_params_roundtrip() {
518        use rmcp::model::{ElicitationSchema, EnumSchema};
519
520        let params = ElicitationParams {
521            server_name: "github".to_string(),
522            request: CreateElicitationRequestParams::FormElicitationParams {
523                meta: None,
524                message: "Pick a color".to_string(),
525                requested_schema: ElicitationSchema::builder()
526                    .required_enum_schema(
527                        "color",
528                        EnumSchema::builder(vec!["red".into(), "green".into(), "blue".into()]).untitled().build(),
529                    )
530                    .build()
531                    .unwrap(),
532            },
533        };
534
535        let untyped = params.to_untyped_message().expect("serializable");
536        assert_eq!(untyped.method(), "_aether/elicitation");
537        let parsed = ElicitationParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
538        assert_eq!(parsed, params);
539    }
540
541    #[test]
542    fn elicitation_params_url_variant_has_mode_field() {
543        let params = ElicitationParams {
544            server_name: "github".to_string(),
545            request: CreateElicitationRequestParams::UrlElicitationParams {
546                meta: None,
547                message: "Authorize GitHub".to_string(),
548                url: "https://github.com/login/oauth".to_string(),
549                elicitation_id: "el-123".to_string(),
550            },
551        };
552
553        let json = serde_json::to_string(&params).unwrap();
554        assert!(json.contains("\"mode\":\"url\""));
555        assert!(json.contains("\"server_name\":\"github\""));
556    }
557
558    #[test]
559    fn mcp_server_status_entry_serde_roundtrip() {
560        let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
561            .with_auth_capability(McpServerAuthCapability::OAuth);
562
563        let json = serde_json::to_string(&entry).unwrap();
564        assert!(json.contains("\"auth_capability\":\"OAuth\""));
565        assert!(json.contains("\"proxied\":false"));
566        let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
567        assert_eq!(parsed, entry);
568        assert!(!parsed.proxied);
569        assert!(parsed.can_authenticate());
570    }
571
572    #[test]
573    fn mcp_server_status_entry_proxied_serde_roundtrip() {
574        let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
575            .with_auth_capability(McpServerAuthCapability::OAuth)
576            .with_proxied(true);
577
578        let json = serde_json::to_string(&entry).unwrap();
579        assert!(json.contains("\"proxied\":true"));
580        let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
581        assert_eq!(parsed, entry);
582    }
583
584    #[test]
585    fn deserialize_tool_call_event() {
586        let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
587        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
588        assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
589    }
590
591    #[test]
592    fn deserialize_tool_call_update_event() {
593        let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
594        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
595        assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
596    }
597
598    #[test]
599    fn deserialize_tool_result_event() {
600        let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
601        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
602        match event {
603            SubAgentEvent::ToolResult { result } => {
604                let result_meta = result.result_meta.expect("expected result_meta");
605                assert_eq!(result_meta.display.title, "Grep");
606            }
607            other => panic!("Expected ToolResult, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn deserialize_tool_error_event() {
613        let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
614        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
615        assert!(matches!(event, SubAgentEvent::ToolError { .. }));
616    }
617
618    #[test]
619    fn deserialize_done_event() {
620        let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
621        assert!(matches!(event, SubAgentEvent::Done));
622    }
623
624    #[test]
625    fn deserialize_other_variant() {
626        let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
627        assert!(matches!(event, SubAgentEvent::Other));
628    }
629
630    #[test]
631    fn tool_result_meta_map_roundtrip() {
632        let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
633        let map = meta.clone().into_map();
634        let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
635        assert_eq!(parsed, meta);
636    }
637}