1use std::io::Write;
11
12use agent_base::{AgentResult, RuntimeEvent, UserEvent};
13
14use crate::session::SessionContext;
15
16pub fn save_turn_log(
21 session_ctx: &SessionContext,
22 turn: u32,
23 events: &[RuntimeEvent],
24 user_input: &str,
25) -> AgentResult<()> {
26 let turn_path = session_ctx.turn_path(turn as usize);
27 let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&turn_path)?;
28
29 let meta = serde_json::json!({
30 "turn": turn,
31 "timestamp": chrono::Utc::now().to_rfc3339(),
32 "user_input": user_input,
33 });
34 writeln!(file, "{}", serde_json::to_string(&meta)?)?;
35
36 for event in events {
37 let line = event_to_jsonl(event);
38 writeln!(file, "{}", line)?;
39 }
40
41 writeln!(file, "{}", serde_json::to_string(&serde_json::json!({"type": "turn_end", "turn": turn}))?)?;
42
43 file.flush()?;
44
45 Ok(())
46}
47
48pub fn event_to_value(event: &RuntimeEvent) -> serde_json::Value {
50 let mut value = match event {
51 RuntimeEvent::ThoughtDelta { text, .. } => {
52 serde_json::json!({"type": "thought_delta", "text": text})
53 },
54 RuntimeEvent::TextDelta { text, .. } => {
55 serde_json::json!({"type": "text_delta", "text": text})
56 },
57 RuntimeEvent::ToolCallStarted { tool_name, args_json, .. } => {
58 let args: serde_json::Value = serde_json::from_str(args_json).unwrap_or(serde_json::Value::Null);
59 serde_json::json!({"type": "tool_call_started", "tool": tool_name, "args": args})
60 },
61 RuntimeEvent::ToolCallFinished { tool_name, summary, denied, .. } => {
62 serde_json::json!({"type": "tool_call_finished", "tool": tool_name, "summary": summary, "denied": denied})
63 },
64 RuntimeEvent::AwaitingApproval { request, .. } => {
65 serde_json::json!({"type": "approval_request", "title": request.title, "message": request.message})
66 },
67 RuntimeEvent::PlanUpdated { explanation, plan, .. } => {
68 serde_json::json!({"type": "plan_updated", "explanation": explanation, "plan": plan})
69 },
70 RuntimeEvent::UserEvent { event: UserEvent::Structured { event_type, data }, .. } => {
71 serde_json::json!({"type": "user_event", "event_type": event_type, "data": data})
72 },
73 RuntimeEvent::UserEvent { .. } => serde_json::json!({"type": "other"}),
74 RuntimeEvent::RunCancelled { .. } => serde_json::json!({"type": "run_cancelled"}),
75 RuntimeEvent::RunFinished { .. } => serde_json::json!({"type": "run_finished"}),
76 RuntimeEvent::Checkpoint { .. } => serde_json::json!({"type": "checkpoint"}),
77 };
78 if let Some(agent_id) = event.agent_id() {
79 value["agent_id"] = serde_json::json!(agent_id);
80 }
81 value
82}
83
84pub fn event_to_jsonl(event: &RuntimeEvent) -> String {
86 let value = event_to_value(event);
87 serde_json::to_string(&value).unwrap_or_else(|_| r#"{"type":"serialize_error"}"#.to_string())
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use agent_base::{SessionId, UserEvent};
94 use tempfile::TempDir;
95
96 fn session_id() -> SessionId {
97 SessionId { id: 1, external_id: None }
98 }
99
100 #[test]
103 fn test_event_to_value_text_delta() {
104 let v = event_to_value(&RuntimeEvent::TextDelta {
105 session_id: session_id(),
106 text: "hello".into(),
107 agent_id: None,
108 trace_id: None,
109 });
110 assert_eq!(v["type"], "text_delta");
111 assert_eq!(v["text"], "hello");
112 }
113
114 #[test]
115 fn test_event_to_value_thought_delta() {
116 let v = event_to_value(&RuntimeEvent::ThoughtDelta {
117 session_id: session_id(),
118 text: "thinking".into(),
119 agent_id: None,
120 trace_id: None,
121 });
122 assert_eq!(v["type"], "thought_delta");
123 }
124
125 #[test]
126 fn test_event_to_value_tool_call_started() {
127 let v = event_to_value(&RuntimeEvent::ToolCallStarted {
128 session_id: session_id(),
129 tool_name: "shell".into(),
130 args_json: r#"{"cmd":"ls"}"#.into(),
131 agent_id: None,
132 trace_id: None,
133 });
134 assert_eq!(v["type"], "tool_call_started");
135 assert_eq!(v["tool"], "shell");
136 assert_eq!(v["args"]["cmd"], "ls");
137 }
138
139 #[test]
140 fn test_event_to_value_tool_call_finished() {
141 let v = event_to_value(&RuntimeEvent::ToolCallFinished {
142 session_id: session_id(),
143 tool_name: "shell".into(),
144 summary: "done".into(),
145 agent_id: None,
146 trace_id: None,
147 denied: false,
148 details: None,
149 });
150 assert_eq!(v["type"], "tool_call_finished");
151 assert_eq!(v["summary"], "done");
152 assert_eq!(v["denied"], false);
153 }
154
155 #[test]
156 fn test_event_to_value_run_finished() {
157 let v = event_to_value(&RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None });
158 assert_eq!(v["type"], "run_finished");
159 }
160
161 #[test]
162 fn test_event_to_value_run_cancelled() {
163 let v =
164 event_to_value(&RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None });
165 assert_eq!(v["type"], "run_cancelled");
166 }
167
168 #[test]
169 fn test_event_to_value_user_event_other() {
170 let v = event_to_value(&RuntimeEvent::UserEvent {
171 session_id: session_id(),
172 event: UserEvent::Progress { text: "loading".into() },
173 agent_id: None,
174 trace_id: None,
175 });
176 assert_eq!(v["type"], "other");
177 }
178
179 #[test]
180 fn test_event_to_value_user_event_structured() {
181 let v = event_to_value(&RuntimeEvent::UserEvent {
182 session_id: session_id(),
183 event: UserEvent::Structured { event_type: "custom".into(), data: serde_json::json!({"key": "value"}) },
184 agent_id: None,
185 trace_id: None,
186 });
187 assert_eq!(v["type"], "user_event");
188 assert_eq!(v["event_type"], "custom");
189 }
190
191 #[test]
194 fn test_event_to_jsonl_returns_valid_json() {
195 let line = event_to_jsonl(&RuntimeEvent::TextDelta {
196 session_id: session_id(),
197 text: "hello".into(),
198 agent_id: None,
199 trace_id: None,
200 });
201 let v: serde_json::Value = serde_json::from_str(&line).unwrap();
202 assert_eq!(v["type"], "text_delta");
203 }
204
205 #[test]
206 fn test_event_to_jsonl_all_variants_valid() {
207 let events: Vec<RuntimeEvent> = vec![
208 RuntimeEvent::TextDelta { session_id: session_id(), text: "t".into(), agent_id: None, trace_id: None },
209 RuntimeEvent::ThoughtDelta { session_id: session_id(), text: "t".into(), agent_id: None, trace_id: None },
210 RuntimeEvent::ToolCallStarted {
211 session_id: session_id(),
212 tool_name: "t".into(),
213 args_json: "{}".into(),
214 agent_id: None,
215 trace_id: None,
216 },
217 RuntimeEvent::ToolCallFinished {
218 session_id: session_id(),
219 tool_name: "t".into(),
220 summary: "s".into(),
221 agent_id: None,
222 trace_id: None,
223 denied: false,
224 details: None,
225 },
226 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
227 RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
228 RuntimeEvent::UserEvent {
229 session_id: session_id(),
230 event: UserEvent::Progress { text: "t".into() },
231 agent_id: None,
232 trace_id: None,
233 },
234 ];
235 for e in &events {
236 let line = event_to_jsonl(e);
237 assert!(serde_json::from_str::<serde_json::Value>(&line).is_ok(), "Failed to parse JSONL for event");
238 }
239 }
240
241 fn make_session_ctx(tmp: &TempDir) -> SessionContext {
244 crate::session::resolve_session(Some("test-session"), tmp.path()).unwrap()
245 }
246
247 #[test]
248 fn test_save_turn_log_creates_file() {
249 let tmp = TempDir::new().unwrap();
250 let ctx = make_session_ctx(&tmp);
251 let events = vec![RuntimeEvent::TextDelta {
252 session_id: session_id(),
253 text: "hello".into(),
254 agent_id: None,
255 trace_id: None,
256 }];
257 save_turn_log(&ctx, 1, &events, "test input").unwrap();
258 let path = ctx.turn_path(1);
259 assert!(path.exists());
260 let content = std::fs::read_to_string(&path).unwrap();
261 assert!(!content.is_empty());
262 }
263
264 #[test]
265 fn test_save_turn_log_contains_metadata() {
266 let tmp = TempDir::new().unwrap();
267 let ctx = make_session_ctx(&tmp);
268 let events = vec![RuntimeEvent::TextDelta {
269 session_id: session_id(),
270 text: "hello".into(),
271 agent_id: None,
272 trace_id: None,
273 }];
274 save_turn_log(&ctx, 1, &events, "my input").unwrap();
275 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
276 let lines: Vec<&str> = content.lines().collect();
277 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
278 assert_eq!(first["turn"], 1);
279 assert_eq!(first["user_input"], "my input");
280 assert!(first["timestamp"].as_str().is_some());
281 }
282
283 #[test]
284 fn test_save_turn_log_contains_events() {
285 let tmp = TempDir::new().unwrap();
286 let ctx = make_session_ctx(&tmp);
287 let events = vec![
288 RuntimeEvent::TextDelta { session_id: session_id(), text: "hello".into(), agent_id: None, trace_id: None },
289 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
290 ];
291 save_turn_log(&ctx, 1, &events, "input").unwrap();
292 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
293 let lines: Vec<&str> = content.lines().collect();
294 assert!(lines.len() >= 4);
296 let line1: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
297 assert_eq!(line1["type"], "text_delta");
298 let line2: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
299 assert_eq!(line2["type"], "run_finished");
300 }
301
302 #[test]
303 fn test_save_turn_log_contains_turn_end_marker() {
304 let tmp = TempDir::new().unwrap();
305 let ctx = make_session_ctx(&tmp);
306 let events = vec![RuntimeEvent::TextDelta {
307 session_id: session_id(),
308 text: "hello".into(),
309 agent_id: None,
310 trace_id: None,
311 }];
312 save_turn_log(&ctx, 1, &events, "input").unwrap();
313 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
314 let last_line = content.lines().last().unwrap();
315 let v: serde_json::Value = serde_json::from_str(last_line).unwrap();
316 assert_eq!(v["type"], "turn_end");
317 assert_eq!(v["turn"], 1);
318 }
319
320 #[test]
321 fn test_save_turn_log_appends() {
322 let tmp = TempDir::new().unwrap();
323 let ctx = make_session_ctx(&tmp);
324 let events1 = vec![RuntimeEvent::TextDelta {
325 session_id: session_id(),
326 text: "turn1".into(),
327 agent_id: None,
328 trace_id: None,
329 }];
330 let events2 = vec![RuntimeEvent::TextDelta {
331 session_id: session_id(),
332 text: "turn2".into(),
333 agent_id: None,
334 trace_id: None,
335 }];
336 save_turn_log(&ctx, 1, &events1, "input1").unwrap();
337 save_turn_log(&ctx, 1, &events2, "input2").unwrap();
338 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
339 assert!(content.contains("turn1"));
340 assert!(content.contains("turn2"));
341 let turn_end_count = content.lines().filter(|l| l.contains("turn_end")).count();
343 assert_eq!(turn_end_count, 2);
344 }
345
346 #[test]
347 fn test_save_turn_log_empty_events() {
348 let tmp = TempDir::new().unwrap();
349 let ctx = make_session_ctx(&tmp);
350 save_turn_log(&ctx, 1, &[], "no events").unwrap();
351 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
352 let lines: Vec<&str> = content.lines().collect();
353 assert_eq!(lines.len(), 2);
355 let last: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
356 assert_eq!(last["type"], "turn_end");
357 assert_eq!(last["turn"], 1);
358 }
359
360 #[test]
363 fn test_event_to_value_attaches_agent_id() {
364 let v = event_to_value(&RuntimeEvent::TextDelta {
365 session_id: session_id(),
366 text: "result".into(),
367 agent_id: Some("root/searcher".into()),
368 trace_id: None,
369 });
370 assert_eq!(v["type"], "text_delta");
371 assert_eq!(v["text"], "result");
372 assert_eq!(v["agent_id"], "root/searcher");
373 }
374
375 #[test]
376 fn test_event_to_value_omits_agent_id_when_none() {
377 let v = event_to_value(&RuntimeEvent::TextDelta {
378 session_id: session_id(),
379 text: "result".into(),
380 agent_id: None,
381 trace_id: None,
382 });
383 assert_eq!(v["type"], "text_delta");
384 assert!(v.get("agent_id").is_none());
385 }
386}