Skip to main content

aether_core/events/
agent_message.rs

1use acp_utils::notifications::{
2    SubAgentEvent, SubAgentToolCallUpdate, SubAgentToolError, SubAgentToolRequest,
3    SubAgentToolResult,
4};
5use llm::{ToolCallError, ToolCallRequest, ToolCallResult};
6use mcp_utils::display_meta::ToolResultMeta;
7use serde::{Deserialize, Serialize};
8
9/// Message from the agent to the user.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum AgentMessage {
12    Text {
13        message_id: String,
14        chunk: String,
15        is_complete: bool,
16        model_name: String,
17    },
18
19    Thought {
20        message_id: String,
21        chunk: String,
22        is_complete: bool,
23        model_name: String,
24    },
25
26    ToolCall {
27        request: ToolCallRequest,
28        model_name: String,
29    },
30
31    ToolCallUpdate {
32        tool_call_id: String,
33        chunk: String,
34        model_name: String,
35    },
36
37    ToolProgress {
38        request: ToolCallRequest,
39        progress: f64,
40        total: Option<f64>,
41        message: Option<String>,
42    },
43
44    ToolResult {
45        result: ToolCallResult,
46        result_meta: Option<ToolResultMeta>,
47        model_name: String,
48    },
49
50    ToolError {
51        error: ToolCallError,
52        model_name: String,
53    },
54
55    Error {
56        message: String,
57    },
58
59    Cancelled {
60        message: String,
61    },
62
63    /// Context compaction has been triggered.
64    ContextCompactionStarted {
65        message_count: usize,
66    },
67
68    /// Context was compacted to reduce token usage.
69    ContextCompactionResult {
70        summary: String,
71        messages_removed: usize,
72    },
73
74    /// Context usage update for UI display.
75    ContextUsageUpdate {
76        /// Current usage ratio (0.0 - 1.0), if context window is known.
77        usage_ratio: Option<f64>,
78        /// Tokens used in current context.
79        tokens_used: u32,
80        /// Maximum context limit, if known.
81        context_limit: Option<u32>,
82    },
83
84    /// Agent is auto-continuing because LLM stopped with a resumable stop reason.
85    AutoContinue {
86        /// Current attempt number (1-indexed).
87        attempt: u32,
88        /// Maximum allowed attempts.
89        max_attempts: u32,
90    },
91
92    /// The model was successfully switched.
93    ModelSwitched {
94        previous: String,
95        new: String,
96    },
97
98    /// The agent context was cleared and reset to its blank state.
99    ContextCleared,
100
101    Done,
102}
103
104impl From<&AgentMessage> for SubAgentEvent {
105    fn from(msg: &AgentMessage) -> Self {
106        match msg {
107            AgentMessage::ToolCall { request, .. } => SubAgentEvent::ToolCall {
108                request: SubAgentToolRequest {
109                    id: request.id.clone(),
110                    name: request.name.clone(),
111                    arguments: request.arguments.clone(),
112                },
113            },
114            AgentMessage::ToolCallUpdate {
115                tool_call_id,
116                chunk,
117                ..
118            } => SubAgentEvent::ToolCallUpdate {
119                update: SubAgentToolCallUpdate {
120                    id: tool_call_id.clone(),
121                    chunk: chunk.clone(),
122                },
123            },
124            AgentMessage::ToolResult {
125                result,
126                result_meta,
127                ..
128            } => SubAgentEvent::ToolResult {
129                result: SubAgentToolResult {
130                    id: result.id.clone(),
131                    name: result.name.clone(),
132                    result_meta: result_meta.clone(),
133                },
134            },
135            AgentMessage::ToolError { error, .. } => SubAgentEvent::ToolError {
136                error: SubAgentToolError {
137                    id: error.id.clone(),
138                    name: error.name.clone(),
139                },
140            },
141            AgentMessage::Done => SubAgentEvent::Done,
142            _ => SubAgentEvent::Other,
143        }
144    }
145}
146
147impl AgentMessage {
148    pub fn text(message_id: &str, chunk: &str, is_complete: bool, model_name: &str) -> Self {
149        AgentMessage::Text {
150            message_id: message_id.to_string(),
151            chunk: chunk.to_string(),
152            is_complete,
153            model_name: model_name.to_string(),
154        }
155    }
156
157    pub fn thought(message_id: &str, chunk: &str, is_complete: bool, model_name: &str) -> Self {
158        AgentMessage::Thought {
159            message_id: message_id.to_string(),
160            chunk: chunk.to_string(),
161            is_complete,
162            model_name: model_name.to_string(),
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::AgentMessage;
170    use acp_utils::notifications::SubAgentEvent;
171    use llm::ToolCallResult;
172    use mcp_utils::display_meta::ToolDisplayMeta;
173
174    #[test]
175    fn test_model_switched_serde_roundtrip() {
176        let msg = AgentMessage::ModelSwitched {
177            previous: "anthropic:claude-3.5-sonnet".to_string(),
178            new: "ollama:llama3.2".to_string(),
179        };
180        let json = serde_json::to_string(&msg).unwrap();
181        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
182        assert_eq!(parsed, msg);
183    }
184
185    #[test]
186    fn test_thought_serde_roundtrip() {
187        let msg = AgentMessage::Thought {
188            message_id: "msg_1".to_string(),
189            chunk: "thinking".to_string(),
190            is_complete: false,
191            model_name: "test-model".to_string(),
192        };
193        let json = serde_json::to_string(&msg).unwrap();
194        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
195        assert_eq!(parsed, msg);
196    }
197
198    #[test]
199    fn test_thought_complete_serde_roundtrip() {
200        let msg = AgentMessage::Thought {
201            message_id: "msg_1".to_string(),
202            chunk: "full reasoning".to_string(),
203            is_complete: true,
204            model_name: "test-model".to_string(),
205        };
206        let json = serde_json::to_string(&msg).unwrap();
207        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
208        assert_eq!(parsed, msg);
209    }
210
211    #[test]
212    fn test_tool_result_serializes_result_meta() {
213        let msg = AgentMessage::ToolResult {
214            result: ToolCallResult {
215                id: "call_1".to_string(),
216                name: "coding__read_file".to_string(),
217                arguments: r#"{"filePath":"Cargo.toml"}"#.to_string(),
218                result: "ok".to_string(),
219            },
220            result_meta: Some(ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into()),
221            model_name: "test-model".to_string(),
222        };
223
224        let json = serde_json::to_value(&msg).unwrap();
225        let tool_result = &json["ToolResult"];
226        assert_eq!(tool_result["result_meta"]["display"]["title"], "Read file");
227        assert_eq!(
228            tool_result["result_meta"]["display"]["value"],
229            "Cargo.toml, 156 lines"
230        );
231
232        let parsed: AgentMessage = serde_json::from_value(json).unwrap();
233        assert_eq!(parsed, msg);
234    }
235
236    #[test]
237    fn test_sub_agent_tool_result_includes_display_fields() {
238        let msg = AgentMessage::ToolResult {
239            result: ToolCallResult {
240                id: "call_1".to_string(),
241                name: "coding__read_file".to_string(),
242                arguments: r#"{"filePath":"Cargo.toml"}"#.to_string(),
243                result: "ok".to_string(),
244            },
245            result_meta: Some(ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into()),
246            model_name: "test-model".to_string(),
247        };
248
249        let event: SubAgentEvent = (&msg).into();
250        match event {
251            SubAgentEvent::ToolResult { result } => {
252                assert_eq!(result.id, "call_1");
253                assert_eq!(result.name, "coding__read_file");
254                let result_meta = result.result_meta.expect("result_meta should be present");
255                assert_eq!(result_meta.display.title, "Read file");
256                assert_eq!(result_meta.display.value, "Cargo.toml, 156 lines");
257            }
258            other => panic!("Expected ToolResult, got {other:?}"),
259        }
260    }
261
262    #[test]
263    fn test_sub_agent_tool_call_update_includes_updated_fields() {
264        let msg = AgentMessage::ToolCallUpdate {
265            tool_call_id: "call_1".to_string(),
266            chunk: r#"{"filePath":"Cargo.toml"}"#.to_string(),
267            model_name: "test-model".to_string(),
268        };
269
270        let event: SubAgentEvent = (&msg).into();
271        match event {
272            SubAgentEvent::ToolCallUpdate { update } => {
273                assert_eq!(update.id, "call_1");
274                assert_eq!(update.chunk, r#"{"filePath":"Cargo.toml"}"#);
275            }
276            other => panic!("Expected ToolCallUpdate, got {other:?}"),
277        }
278    }
279}