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