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 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}