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};
8pub use rmcp::model::ElicitRequestParams;
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: ElicitRequestParams,
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) -> Meta {
238        to_aether_meta(self)
239    }
240
241    #[must_use]
242    pub fn from_meta(meta: Option<&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) -> Meta {
261        to_aether_meta(&self)
262    }
263
264    #[must_use]
265    pub fn from_meta(meta: Option<&Meta>) -> Self {
266        from_aether_meta(meta)
267    }
268}
269
270fn to_aether_meta<T: Serialize>(value: &T) -> Meta {
271    let mut meta = 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<&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
291/// Server→client MCP extension notifications (relay → wisp).
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
293#[notification(method = "_aether/mcp_event")]
294pub enum McpNotification {
295    ServerStatus { servers: Vec<McpServerStatusEntry> },
296}
297
298/// Client→server MCP extension requests (wisp → relay).
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonRpcNotification)]
300#[notification(method = "_aether/mcp_request")]
301pub enum McpRequest {
302    Authenticate { session_id: String, server_name: String },
303}
304
305/// Parameters for `_aether/sub_agent_progress` notifications.
306///
307/// This is the wire format sent from the ACP server (`aether-cli`) to clients like `wisp`.
308#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcNotification)]
309#[notification(method = "_aether/sub_agent_progress")]
310pub struct SubAgentProgressParams {
311    pub parent_tool_id: String,
312    pub task_id: String,
313    pub agent_name: String,
314    pub event: SubAgentEvent,
315}
316
317/// Subset of agent message variants relevant for sub-agent status display.
318///
319/// The ACP server (`aether-cli`) converts `AgentEvent` to this type before
320/// serializing, so the wire format only contains these known variants.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum SubAgentEvent {
323    ToolCall { request: SubAgentToolRequest },
324    ToolCallUpdate { update: SubAgentToolCallUpdate },
325    ToolResult { result: SubAgentToolResult },
326    ToolError { error: SubAgentToolError },
327    Done,
328    Other,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct SubAgentToolRequest {
333    pub id: String,
334    pub name: String,
335    pub arguments: String,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct SubAgentToolCallUpdate {
340    pub id: String,
341    pub chunk: String,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct SubAgentToolResult {
346    pub id: String,
347    pub name: String,
348    pub result_meta: Option<ToolResultMeta>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct SubAgentToolError {
353    pub id: String,
354    pub name: String,
355}
356
357#[cfg(test)]
358mod tests {
359    use agent_client_protocol::JsonRpcMessage;
360    use agent_client_protocol::schema::v1::AuthMethodAgent;
361
362    use super::*;
363
364    #[test]
365    fn wire_method_names_are_prefixed() {
366        assert_eq!(ContextClearedParams::default().method(), "_aether/context_cleared");
367        assert_eq!(AuthMethodsUpdatedParams { auth_methods: vec![] }.method(), "_aether/auth_methods_updated");
368        assert_eq!(McpNotification::ServerStatus { servers: vec![] }.method(), "_aether/mcp_event");
369        assert_eq!(
370            McpRequest::Authenticate { session_id: String::new(), server_name: String::new() }.method(),
371            "_aether/mcp_request"
372        );
373        assert_eq!(PromptSearchParams { query: String::new(), limit: None }.method(), "_aether/prompt_search");
374        assert_eq!(SessionPreviewParams { session_id: String::new() }.method(), "_aether/session_preview");
375        assert_eq!(WorkspaceListParams { session_id: String::new() }.method(), "_aether/workspace_list");
376        let move_params =
377            WorkspaceMoveParams { session_id: String::new(), target: WorkspaceMoveTarget::New { name: String::new() } };
378        assert_eq!(move_params.method(), "_aether/workspace_move");
379    }
380
381    #[test]
382    fn context_usage_params_roundtrip() {
383        let params = ContextUsageParams {
384            usage: ContextUsage {
385                usage_ratio: Some(0.75),
386                context_limit: Some(100_000),
387                input_tokens: 75_000,
388                output_tokens: 1_200,
389                cache_read_tokens: Some(40_000),
390                cache_creation_tokens: Some(2_000),
391                reasoning_tokens: Some(500),
392                total_input_tokens: 200_000,
393                total_output_tokens: 8_000,
394                total_cache_read_tokens: 90_000,
395                total_cache_creation_tokens: 5_000,
396                total_reasoning_tokens: 1_500,
397            },
398        };
399
400        let untyped = params.to_untyped_message().expect("serializable");
401        assert_eq!(untyped.method(), "_aether/context_usage");
402        let parsed = ContextUsageParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
403        assert_eq!(parsed, params);
404    }
405
406    #[test]
407    fn context_usage_params_omits_unset_optional_token_fields() {
408        let params = ContextUsageParams {
409            usage: ContextUsage {
410                usage_ratio: Some(0.1),
411                context_limit: Some(1_000),
412                input_tokens: 100,
413                ..ContextUsage::default()
414            },
415        };
416
417        let raw = serde_json::to_string(&params).unwrap();
418        assert!(!raw.contains("\"cache_read_tokens\""));
419        assert!(!raw.contains("\"cache_creation_tokens\""));
420        assert!(!raw.contains("\"reasoning_tokens\""));
421    }
422
423    #[test]
424    fn context_compaction_params_roundtrip() {
425        for active in [true, false] {
426            let params = ContextCompactionParams { active };
427            let untyped = params.to_untyped_message().expect("serializable");
428            assert_eq!(untyped.method(), "_aether/context_compaction");
429            let parsed = ContextCompactionParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
430            assert_eq!(parsed, params);
431        }
432    }
433
434    #[test]
435    fn context_cleared_params_roundtrip() {
436        let params = ContextClearedParams::default();
437        let untyped = params.to_untyped_message().expect("serializable");
438        assert_eq!(untyped.method(), "_aether/context_cleared");
439        let parsed = ContextClearedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
440        assert_eq!(parsed, params);
441    }
442
443    #[test]
444    fn auth_methods_updated_roundtrip() {
445        let params = AuthMethodsUpdatedParams {
446            auth_methods: vec![
447                AuthMethod::Agent(AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
448                AuthMethod::Agent(AuthMethodAgent::new("openrouter", "OpenRouter")),
449            ],
450        };
451
452        let untyped = params.to_untyped_message().expect("serializable");
453        assert_eq!(untyped.method(), "_aether/auth_methods_updated");
454        let parsed = AuthMethodsUpdatedParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
455        assert_eq!(parsed, params);
456    }
457
458    #[test]
459    fn mcp_request_authenticate_roundtrip() {
460        let msg = McpRequest::Authenticate {
461            session_id: "session-0".to_string(),
462            server_name: "my oauth server".to_string(),
463        };
464
465        let untyped = msg.to_untyped_message().expect("serializable");
466        assert_eq!(untyped.method(), "_aether/mcp_request");
467        let parsed = McpRequest::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
468        assert_eq!(parsed, msg);
469    }
470
471    #[test]
472    fn mcp_notification_server_status_roundtrip() {
473        let msg = McpNotification::ServerStatus {
474            servers: vec![
475                McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
476                McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
477                    .with_auth_capability(McpServerAuthCapability::OAuth),
478                McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
479            ],
480        };
481
482        let untyped = msg.to_untyped_message().expect("serializable");
483        assert_eq!(untyped.method(), "_aether/mcp_event");
484        let parsed = McpNotification::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
485        assert_eq!(parsed, msg);
486    }
487
488    #[test]
489    fn sub_agent_progress_params_roundtrip() {
490        let params = SubAgentProgressParams {
491            parent_tool_id: "call_123".to_string(),
492            task_id: "task_abc".to_string(),
493            agent_name: "explorer".to_string(),
494            event: SubAgentEvent::Done,
495        };
496
497        let untyped = params.to_untyped_message().expect("serializable");
498        assert_eq!(untyped.method(), "_aether/sub_agent_progress");
499    }
500
501    #[test]
502    fn elicitation_params_roundtrip() {
503        use rmcp::model::{ElicitationSchema, EnumSchema};
504
505        let params = ElicitationParams {
506            server_name: "github".to_string(),
507            request: ElicitRequestParams::FormElicitationParams {
508                meta: None,
509                message: "Pick a color".to_string(),
510                requested_schema: ElicitationSchema::builder()
511                    .required_enum_schema(
512                        "color",
513                        EnumSchema::builder(vec!["red".into(), "green".into(), "blue".into()]).untitled().build(),
514                    )
515                    .build()
516                    .unwrap(),
517            },
518        };
519
520        let untyped = params.to_untyped_message().expect("serializable");
521        assert_eq!(untyped.method(), "_aether/elicitation");
522        let parsed = ElicitationParams::parse_message(untyped.method(), untyped.params()).expect("roundtrip");
523        assert_eq!(parsed, params);
524    }
525
526    #[test]
527    fn elicitation_params_url_variant_has_mode_field() {
528        let params = ElicitationParams {
529            server_name: "github".to_string(),
530            request: ElicitRequestParams::UrlElicitationParams {
531                meta: None,
532                message: "Authorize GitHub".to_string(),
533                url: "https://github.com/login/oauth".to_string(),
534                elicitation_id: "el-123".to_string(),
535            },
536        };
537
538        let json = serde_json::to_string(&params).unwrap();
539        assert!(json.contains("\"mode\":\"url\""));
540        assert!(json.contains("\"server_name\":\"github\""));
541    }
542
543    #[test]
544    fn mcp_server_status_entry_serde_roundtrip() {
545        let entry = McpServerStatusEntry::new("test-server", McpServerStatus::Connected { tool_count: 3 })
546            .with_auth_capability(McpServerAuthCapability::OAuth);
547
548        let json = serde_json::to_string(&entry).unwrap();
549        assert!(json.contains("\"auth_capability\":\"OAuth\""));
550        assert!(json.contains("\"deferTools\":false"));
551        let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
552        assert_eq!(parsed, entry);
553        assert!(!parsed.deferred_tools);
554        assert!(parsed.can_authenticate());
555    }
556
557    #[test]
558    fn mcp_server_status_entry_deferred_tools_serde_roundtrip() {
559        let entry = McpServerStatusEntry::new("math", McpServerStatus::NeedsOAuth)
560            .with_auth_capability(McpServerAuthCapability::OAuth)
561            .with_deferred_tools(true);
562
563        let json = serde_json::to_string(&entry).unwrap();
564        assert!(json.contains("\"deferTools\":true"));
565        let parsed: McpServerStatusEntry = serde_json::from_str(&json).unwrap();
566        assert_eq!(parsed, entry);
567    }
568
569    #[test]
570    fn deserialize_tool_call_event() {
571        let json = r#"{"ToolCall":{"request":{"id":"c1","name":"grep","arguments":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
572        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
573        assert!(matches!(event, SubAgentEvent::ToolCall { .. }));
574    }
575
576    #[test]
577    fn deserialize_tool_call_update_event() {
578        let json = r#"{"ToolCallUpdate":{"update":{"id":"c1","chunk":"{\"pattern\":\"test\"}"},"model_name":"m"}}"#;
579        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
580        assert!(matches!(event, SubAgentEvent::ToolCallUpdate { .. }));
581    }
582
583    #[test]
584    fn deserialize_tool_result_event() {
585        let json = r#"{"ToolResult":{"result":{"id":"c1","name":"grep","result_meta":{"display":{"title":"Grep","value":"'test' in src (3 matches)"}}}}}"#;
586        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
587        match event {
588            SubAgentEvent::ToolResult { result } => {
589                let result_meta = result.result_meta.expect("expected result_meta");
590                assert_eq!(result_meta.display.title, "Grep");
591            }
592            other => panic!("Expected ToolResult, got {other:?}"),
593        }
594    }
595
596    #[test]
597    fn deserialize_tool_error_event() {
598        let json = r#"{"ToolError":{"error":{"id":"c1","name":"grep"}}}"#;
599        let event: SubAgentEvent = serde_json::from_str(json).unwrap();
600        assert!(matches!(event, SubAgentEvent::ToolError { .. }));
601    }
602
603    #[test]
604    fn deserialize_done_event() {
605        let event: SubAgentEvent = serde_json::from_str(r#""Done""#).unwrap();
606        assert!(matches!(event, SubAgentEvent::Done));
607    }
608
609    #[test]
610    fn deserialize_other_variant() {
611        let event: SubAgentEvent = serde_json::from_str(r#""Other""#).unwrap();
612        assert!(matches!(event, SubAgentEvent::Other));
613    }
614
615    #[test]
616    fn tool_result_meta_map_roundtrip() {
617        let meta: ToolResultMeta = ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into();
618        let map = meta.clone().into_map();
619        let parsed = ToolResultMeta::from_map(&map).expect("should deserialize ToolResultMeta");
620        assert_eq!(parsed, meta);
621    }
622}