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 });
149 assert_eq!(v["type"], "tool_call_finished");
150 assert_eq!(v["summary"], "done");
151 assert_eq!(v["denied"], false);
152 }
153
154 #[test]
155 fn test_event_to_value_run_finished() {
156 let v = event_to_value(&RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None });
157 assert_eq!(v["type"], "run_finished");
158 }
159
160 #[test]
161 fn test_event_to_value_run_cancelled() {
162 let v =
163 event_to_value(&RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None });
164 assert_eq!(v["type"], "run_cancelled");
165 }
166
167 #[test]
168 fn test_event_to_value_user_event_other() {
169 let v = event_to_value(&RuntimeEvent::UserEvent {
170 session_id: session_id(),
171 event: UserEvent::Progress { text: "loading".into() },
172 agent_id: None,
173 trace_id: None,
174 });
175 assert_eq!(v["type"], "other");
176 }
177
178 #[test]
179 fn test_event_to_value_user_event_structured() {
180 let v = event_to_value(&RuntimeEvent::UserEvent {
181 session_id: session_id(),
182 event: UserEvent::Structured { event_type: "custom".into(), data: serde_json::json!({"key": "value"}) },
183 agent_id: None,
184 trace_id: None,
185 });
186 assert_eq!(v["type"], "user_event");
187 assert_eq!(v["event_type"], "custom");
188 }
189
190 #[test]
193 fn test_event_to_jsonl_returns_valid_json() {
194 let line = event_to_jsonl(&RuntimeEvent::TextDelta {
195 session_id: session_id(),
196 text: "hello".into(),
197 agent_id: None,
198 trace_id: None,
199 });
200 let v: serde_json::Value = serde_json::from_str(&line).unwrap();
201 assert_eq!(v["type"], "text_delta");
202 }
203
204 #[test]
205 fn test_event_to_jsonl_all_variants_valid() {
206 let events: Vec<RuntimeEvent> = vec![
207 RuntimeEvent::TextDelta { session_id: session_id(), text: "t".into(), agent_id: None, trace_id: None },
208 RuntimeEvent::ThoughtDelta { session_id: session_id(), text: "t".into(), agent_id: None, trace_id: None },
209 RuntimeEvent::ToolCallStarted {
210 session_id: session_id(),
211 tool_name: "t".into(),
212 args_json: "{}".into(),
213 agent_id: None,
214 trace_id: None,
215 },
216 RuntimeEvent::ToolCallFinished {
217 session_id: session_id(),
218 tool_name: "t".into(),
219 summary: "s".into(),
220 agent_id: None,
221 trace_id: None,
222 denied: false,
223 },
224 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
225 RuntimeEvent::RunCancelled { session_id: session_id(), agent_id: None, trace_id: None },
226 RuntimeEvent::UserEvent {
227 session_id: session_id(),
228 event: UserEvent::Progress { text: "t".into() },
229 agent_id: None,
230 trace_id: None,
231 },
232 ];
233 for e in &events {
234 let line = event_to_jsonl(e);
235 assert!(serde_json::from_str::<serde_json::Value>(&line).is_ok(), "Failed to parse JSONL for event");
236 }
237 }
238
239 fn make_session_ctx(tmp: &TempDir) -> SessionContext {
242 crate::session::resolve_session(Some("test-session"), tmp.path()).unwrap()
243 }
244
245 #[test]
246 fn test_save_turn_log_creates_file() {
247 let tmp = TempDir::new().unwrap();
248 let ctx = make_session_ctx(&tmp);
249 let events = vec![RuntimeEvent::TextDelta {
250 session_id: session_id(),
251 text: "hello".into(),
252 agent_id: None,
253 trace_id: None,
254 }];
255 save_turn_log(&ctx, 1, &events, "test input").unwrap();
256 let path = ctx.turn_path(1);
257 assert!(path.exists());
258 let content = std::fs::read_to_string(&path).unwrap();
259 assert!(!content.is_empty());
260 }
261
262 #[test]
263 fn test_save_turn_log_contains_metadata() {
264 let tmp = TempDir::new().unwrap();
265 let ctx = make_session_ctx(&tmp);
266 let events = vec![RuntimeEvent::TextDelta {
267 session_id: session_id(),
268 text: "hello".into(),
269 agent_id: None,
270 trace_id: None,
271 }];
272 save_turn_log(&ctx, 1, &events, "my input").unwrap();
273 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
274 let lines: Vec<&str> = content.lines().collect();
275 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
276 assert_eq!(first["turn"], 1);
277 assert_eq!(first["user_input"], "my input");
278 assert!(first["timestamp"].as_str().is_some());
279 }
280
281 #[test]
282 fn test_save_turn_log_contains_events() {
283 let tmp = TempDir::new().unwrap();
284 let ctx = make_session_ctx(&tmp);
285 let events = vec![
286 RuntimeEvent::TextDelta { session_id: session_id(), text: "hello".into(), agent_id: None, trace_id: None },
287 RuntimeEvent::RunFinished { session_id: session_id(), agent_id: None, trace_id: None },
288 ];
289 save_turn_log(&ctx, 1, &events, "input").unwrap();
290 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
291 let lines: Vec<&str> = content.lines().collect();
292 assert!(lines.len() >= 4);
294 let line1: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
295 assert_eq!(line1["type"], "text_delta");
296 let line2: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
297 assert_eq!(line2["type"], "run_finished");
298 }
299
300 #[test]
301 fn test_save_turn_log_contains_turn_end_marker() {
302 let tmp = TempDir::new().unwrap();
303 let ctx = make_session_ctx(&tmp);
304 let events = vec![RuntimeEvent::TextDelta {
305 session_id: session_id(),
306 text: "hello".into(),
307 agent_id: None,
308 trace_id: None,
309 }];
310 save_turn_log(&ctx, 1, &events, "input").unwrap();
311 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
312 let last_line = content.lines().last().unwrap();
313 let v: serde_json::Value = serde_json::from_str(last_line).unwrap();
314 assert_eq!(v["type"], "turn_end");
315 assert_eq!(v["turn"], 1);
316 }
317
318 #[test]
319 fn test_save_turn_log_appends() {
320 let tmp = TempDir::new().unwrap();
321 let ctx = make_session_ctx(&tmp);
322 let events1 = vec![RuntimeEvent::TextDelta {
323 session_id: session_id(),
324 text: "turn1".into(),
325 agent_id: None,
326 trace_id: None,
327 }];
328 let events2 = vec![RuntimeEvent::TextDelta {
329 session_id: session_id(),
330 text: "turn2".into(),
331 agent_id: None,
332 trace_id: None,
333 }];
334 save_turn_log(&ctx, 1, &events1, "input1").unwrap();
335 save_turn_log(&ctx, 1, &events2, "input2").unwrap();
336 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
337 assert!(content.contains("turn1"));
338 assert!(content.contains("turn2"));
339 let turn_end_count = content.lines().filter(|l| l.contains("turn_end")).count();
341 assert_eq!(turn_end_count, 2);
342 }
343
344 #[test]
345 fn test_save_turn_log_empty_events() {
346 let tmp = TempDir::new().unwrap();
347 let ctx = make_session_ctx(&tmp);
348 save_turn_log(&ctx, 1, &[], "no events").unwrap();
349 let content = std::fs::read_to_string(ctx.turn_path(1)).unwrap();
350 let lines: Vec<&str> = content.lines().collect();
351 assert_eq!(lines.len(), 2);
353 let last: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
354 assert_eq!(last["type"], "turn_end");
355 assert_eq!(last["turn"], 1);
356 }
357
358 #[test]
361 fn test_event_to_value_attaches_agent_id() {
362 let v = event_to_value(&RuntimeEvent::TextDelta {
363 session_id: session_id(),
364 text: "result".into(),
365 agent_id: Some("root/searcher".into()),
366 trace_id: None,
367 });
368 assert_eq!(v["type"], "text_delta");
369 assert_eq!(v["text"], "result");
370 assert_eq!(v["agent_id"], "root/searcher");
371 }
372
373 #[test]
374 fn test_event_to_value_omits_agent_id_when_none() {
375 let v = event_to_value(&RuntimeEvent::TextDelta {
376 session_id: session_id(),
377 text: "result".into(),
378 agent_id: None,
379 trace_id: None,
380 });
381 assert_eq!(v["type"], "text_delta");
382 assert!(v.get("agent_id").is_none());
383 }
384}