Skip to main content

aether_core/context/
ext.rs

1use crate::events::{AgentEvent, ContextEvent, MessageEvent, ToolEvent, TurnEvent};
2use crate::session::{SessionEvent, UserEvent};
3use llm::types::IsoString;
4use llm::{AssistantReasoning, ChatMessage, Context, ToolCallError, ToolCallResult};
5
6pub fn conversation_messages_from_events(events: &[SessionEvent]) -> Vec<ChatMessage> {
7    Context::from_events(events).messages().iter().filter(|message| !message.is_system()).cloned().collect()
8}
9
10pub trait ContextExt {
11    fn from_events(events: &[SessionEvent]) -> Self
12    where
13        Self: Sized;
14}
15
16impl ContextExt for Context {
17    fn from_events(events: &[SessionEvent]) -> Self {
18        let mut context = Context::new(vec![], vec![]);
19        let mut acc = TurnAccumulator::default();
20        for event in events {
21            match event {
22                SessionEvent::User(e) => apply_user_event(&mut context, e),
23                SessionEvent::Agent(m) => apply_agent_event(&mut context, m, &mut acc),
24                SessionEvent::Control(_) => {}
25            }
26        }
27        context
28    }
29}
30
31#[derive(Default)]
32struct TurnAccumulator {
33    text: String,
34    reasoning: String,
35    tool_results: Vec<Result<ToolCallResult, ToolCallError>>,
36}
37
38fn apply_user_event(ctx: &mut Context, event: &UserEvent) {
39    match event {
40        UserEvent::Message { content } => {
41            ctx.add_message(ChatMessage::User { content: content.clone(), timestamp: IsoString::now() });
42        }
43        UserEvent::ClearContext => {
44            ctx.clear_conversation();
45        }
46    }
47}
48
49fn apply_agent_event(ctx: &mut Context, event: &AgentEvent, acc: &mut TurnAccumulator) {
50    match event {
51        AgentEvent::Message(MessageEvent::Text { chunk, is_complete: true, .. }) => {
52            acc.text.clone_from(chunk);
53        }
54        AgentEvent::Message(MessageEvent::Thought { chunk, is_complete: true, .. }) => {
55            acc.reasoning.clone_from(chunk);
56        }
57        AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
58            acc.tool_results.push(Ok(result.clone()));
59        }
60        AgentEvent::Tool(ToolEvent::Error { error, .. }) => {
61            acc.tool_results.push(Err(error.clone()));
62        }
63        AgentEvent::Turn(TurnEvent::Ended { .. }) => {
64            let text = std::mem::take(&mut acc.text);
65            let reasoning_text = std::mem::take(&mut acc.reasoning);
66            let tools = std::mem::take(&mut acc.tool_results);
67            if !text.is_empty() || !tools.is_empty() {
68                let reasoning = AssistantReasoning::from_parts(reasoning_text, None);
69                ctx.push_assistant_turn(&text, reasoning, tools);
70            }
71        }
72        AgentEvent::Context(ContextEvent::Cleared) => {
73            ctx.clear_conversation();
74            acc.text.clear();
75            acc.reasoning.clear();
76            acc.tool_results.clear();
77        }
78        AgentEvent::Context(ContextEvent::CompactionResult { summary, .. }) => {
79            *ctx = ctx.with_compacted_summary(summary);
80        }
81        _ => {}
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use crate::events::TurnOutcome;
88    use crate::session::{SessionControlEvent, last_agent_from_events};
89
90    use super::*;
91    use llm::ToolCallResult;
92
93    fn system_context() -> Context {
94        Context::new(vec![ChatMessage::system("You are helpful.")], vec![])
95    }
96
97    fn user_msg(content: &str) -> UserEvent {
98        UserEvent::Message { content: vec![llm::ContentBlock::text(content)] }
99    }
100
101    fn user_session(content: &str) -> SessionEvent {
102        SessionEvent::User(user_msg(content))
103    }
104
105    fn text_complete(chunk: &str) -> AgentEvent {
106        AgentEvent::text("msg_1", chunk, true)
107    }
108
109    fn tool_result(id: &str, name: &str, result: &str) -> AgentEvent {
110        AgentEvent::Tool(ToolEvent::Result {
111            result: ToolCallResult {
112                id: id.to_string(),
113                name: name.to_string(),
114                arguments: "{}".to_string(),
115                result: result.to_string(),
116            },
117            result_meta: None,
118        })
119    }
120
121    fn agent_session(msg: AgentEvent) -> SessionEvent {
122        SessionEvent::Agent(msg)
123    }
124
125    /// Runs a sequence of agent events against a `system_context` and returns the context.
126    fn run_agent_events(events: &[AgentEvent]) -> Context {
127        let mut ctx = system_context();
128        let mut acc = TurnAccumulator::default();
129        for event in events {
130            apply_agent_event(&mut ctx, event, &mut acc);
131        }
132        ctx
133    }
134
135    #[test]
136    fn from_events_ignores_control_events() {
137        let ctx = Context::from_events(&[
138            user_session("Hello"),
139            SessionEvent::Control(SessionControlEvent::AgentSwitched {
140                from: Some("Planner".to_string()),
141                to: Some("Coder".to_string()),
142            }),
143            agent_session(text_complete("Hi there!")),
144            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
145        ]);
146
147        assert_eq!(ctx.message_count(), 2);
148        assert!(matches!(ctx.messages()[0], ChatMessage::User { .. }));
149        assert!(matches!(ctx.messages()[1], ChatMessage::Assistant { .. }));
150    }
151
152    #[test]
153    fn conversation_messages_from_events_filters_system_messages() {
154        let messages = conversation_messages_from_events(&[
155            user_session("Hello"),
156            agent_session(text_complete("Hi there!")),
157            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
158        ]);
159
160        assert_eq!(messages.len(), 2);
161        assert!(messages.iter().all(|message| !message.is_system()));
162    }
163
164    #[test]
165    fn last_agent_from_events_returns_last_switched_agent() {
166        let events = [
167            SessionEvent::Control(SessionControlEvent::AgentSwitched { from: None, to: Some("Planner".to_string()) }),
168            user_session("Hello"),
169            SessionEvent::Control(SessionControlEvent::AgentSwitched {
170                from: Some("Planner".to_string()),
171                to: Some("Coder".to_string()),
172            }),
173        ];
174
175        assert_eq!(last_agent_from_events(Some("Default".to_string()), &events), Some("Coder".to_string()));
176    }
177
178    #[test]
179    fn apply_user_message_adds_user_message() {
180        let mut ctx = system_context();
181        apply_user_event(&mut ctx, &user_msg("Hello"));
182        assert_eq!(ctx.message_count(), 2);
183        match &ctx.messages()[1] {
184            ChatMessage::User { content, .. } => {
185                assert_eq!(content, &vec![llm::ContentBlock::text("Hello")]);
186            }
187            other => panic!("Expected User, got {other:?}"),
188        }
189    }
190
191    #[test]
192    fn apply_user_clear_retains_system_messages() {
193        let mut ctx = system_context();
194        apply_user_event(&mut ctx, &user_msg("Hello"));
195        apply_user_event(&mut ctx, &UserEvent::ClearContext);
196        assert_eq!(ctx.message_count(), 1);
197        assert!(ctx.messages()[0].is_system());
198    }
199
200    #[test]
201    fn apply_agent_produces_assistant_and_tool_results() {
202        let ctx = run_agent_events(&[
203            tool_result("call_1", "read_file", "file contents"),
204            text_complete("Here is the file"),
205            AgentEvent::turn_ended(TurnOutcome::Completed),
206        ]);
207
208        assert_eq!(ctx.message_count(), 3);
209        match &ctx.messages()[1] {
210            ChatMessage::Assistant { content, tool_calls, .. } => {
211                assert_eq!(content, "Here is the file");
212                assert_eq!(tool_calls.len(), 1);
213                assert_eq!(tool_calls[0].name, "read_file");
214            }
215            other => panic!("Expected Assistant, got {other:?}"),
216        }
217        assert!(ctx.messages()[2].is_tool_result());
218    }
219
220    #[test]
221    fn apply_agent_context_cleared() {
222        let mut ctx = system_context();
223        let mut acc = TurnAccumulator::default();
224        apply_user_event(&mut ctx, &user_msg("Hello"));
225        apply_agent_event(&mut ctx, &AgentEvent::Context(ContextEvent::Cleared), &mut acc);
226        assert_eq!(ctx.message_count(), 1);
227        assert!(ctx.messages()[0].is_system());
228    }
229
230    #[test]
231    fn apply_agent_compaction_replaces_with_summary() {
232        let mut ctx = system_context();
233        let mut acc = TurnAccumulator::default();
234        apply_user_event(&mut ctx, &user_msg("Hello"));
235        apply_agent_event(
236            &mut ctx,
237            &AgentEvent::Context(ContextEvent::CompactionResult {
238                summary: "Summary of conversation".to_string(),
239                messages_removed: 1,
240            }),
241            &mut acc,
242        );
243        assert_eq!(ctx.message_count(), 2);
244        assert!(ctx.messages()[0].is_system());
245        assert!(ctx.messages()[1].is_summary());
246    }
247
248    #[test]
249    fn done_without_content_does_not_add_message() {
250        let ctx = run_agent_events(&[AgentEvent::turn_ended(TurnOutcome::Completed)]);
251        assert_eq!(ctx.message_count(), 1);
252    }
253
254    #[test]
255    fn streaming_chunks_are_ignored() {
256        let ctx = run_agent_events(&[AgentEvent::text("msg_1", "partial", false)]);
257        assert_eq!(ctx.message_count(), 1);
258    }
259
260    #[test]
261    fn accumulator_resets_after_done() {
262        let ctx = run_agent_events(&[
263            text_complete("Turn 1"),
264            AgentEvent::turn_ended(TurnOutcome::Completed),
265            AgentEvent::text("msg_2", "Turn 2", true),
266            AgentEvent::turn_ended(TurnOutcome::Completed),
267        ]);
268        assert_eq!(ctx.message_count(), 3);
269    }
270
271    #[test]
272    fn user_event_serde_roundtrip() {
273        let cases: Vec<UserEvent> = vec![user_msg("Hello"), UserEvent::ClearContext];
274        for event in cases {
275            let json = serde_json::to_string(&event).unwrap();
276            let parsed: UserEvent = serde_json::from_str(&json).unwrap();
277            assert_eq!(parsed, event);
278        }
279    }
280
281    #[test]
282    fn from_events_basic_conversation() {
283        let ctx = Context::from_events(&[
284            user_session("Hello"),
285            agent_session(text_complete("Hi there!")),
286            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
287        ]);
288        assert_eq!(ctx.message_count(), 2);
289        assert!(matches!(ctx.messages()[0], ChatMessage::User { .. }));
290        assert!(matches!(ctx.messages()[1], ChatMessage::Assistant { .. }));
291    }
292
293    #[test]
294    fn from_events_with_tool_calls() {
295        let ctx = Context::from_events(&[
296            user_session("Read Cargo.toml"),
297            agent_session(AgentEvent::Tool(ToolEvent::Call {
298                request: llm::ToolCallRequest {
299                    id: "call_1".to_string(),
300                    name: "read_file".to_string(),
301                    arguments: "{}".to_string(),
302                },
303            })),
304            agent_session(tool_result("call_1", "read_file", "file contents")),
305            agent_session(text_complete("Here is the file")),
306            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
307        ]);
308
309        assert_eq!(ctx.message_count(), 3);
310        match &ctx.messages()[1] {
311            ChatMessage::Assistant { tool_calls, .. } => {
312                assert_eq!(tool_calls.len(), 1);
313                assert_eq!(tool_calls[0].name, "read_file");
314            }
315            other => panic!("Expected Assistant, got {other:?}"),
316        }
317        assert!(ctx.messages()[2].is_tool_result());
318    }
319
320    #[test]
321    fn from_events_handles_clear() {
322        let ctx = Context::from_events(&[
323            user_session("Hello"),
324            agent_session(text_complete("Hi!")),
325            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
326            SessionEvent::User(UserEvent::ClearContext),
327            user_session("Start fresh"),
328        ]);
329        assert_eq!(ctx.message_count(), 1);
330        assert!(matches!(ctx.messages()[0], ChatMessage::User { .. }));
331    }
332
333    #[test]
334    fn from_events_handles_compaction() {
335        let ctx = Context::from_events(&[
336            user_session("Hello"),
337            agent_session(text_complete("Hi!")),
338            agent_session(AgentEvent::turn_ended(TurnOutcome::Completed)),
339            agent_session(AgentEvent::Context(ContextEvent::CompactionResult {
340                summary: "Earlier we greeted each other.".to_string(),
341                messages_removed: 2,
342            })),
343            user_session("What did we talk about?"),
344        ]);
345        assert_eq!(ctx.message_count(), 2);
346        assert!(ctx.messages()[0].is_summary());
347    }
348
349    #[test]
350    fn from_events_empty() {
351        let ctx = Context::from_events(&[]);
352        assert_eq!(ctx.message_count(), 0);
353    }
354}