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