1use anyhow::{Context, Result};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::fs::{self, OpenOptions};
13use std::io::{BufRead, BufReader, Write};
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "event")]
19pub enum SessionEvent {
20 #[serde(rename = "start")]
21 Start {
22 session_id: String,
23 agent: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 project: Option<String>,
26 input: String,
27 started_at: String,
28 },
29 #[serde(rename = "turn")]
30 Turn {
31 session_id: String,
32 turn: u32,
33 input: String,
34 output: String,
35 timestamp: String,
36 },
37 #[serde(rename = "tool_call")]
38 ToolCall {
39 session_id: String,
40 tool_call_id: String,
41 tool: String,
42 input: serde_json::Value,
43 timestamp: String,
44 },
45 #[serde(rename = "tool_call_result")]
46 ToolCallResult {
47 session_id: String,
48 tool_call_id: String,
49 tool: String,
50 output: serde_json::Value,
51 duration_ms: u64,
52 timestamp: String,
53 },
54 #[serde(rename = "step")]
55 Step {
56 session_id: String,
57 step: u32,
58 tool: String,
59 args: serde_json::Value,
60 duration_ms: u64,
61 },
62 #[serde(rename = "finish")]
63 Finish {
64 session_id: String,
65 outcome: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 output: Option<String>,
68 iterations: usize,
69 finished_at: String,
70 },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum SessionStatus {
81 Running,
83 Completed,
85 Failed,
87 Cancelled,
89}
90
91impl SessionStatus {
92 pub fn from_outcome(outcome: &str) -> Self {
94 match outcome.to_lowercase().as_str() {
95 "completed" | "success" | "done" => SessionStatus::Completed,
96 "failed" | "error" | "failure" => SessionStatus::Failed,
97 "cancelled" | "canceled" => SessionStatus::Cancelled,
98 _ => SessionStatus::Completed, }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum SortOrder {
106 #[default]
108 NewestFirst,
109 OldestFirst,
111}
112
113#[derive(Debug, Clone, Default)]
115pub struct SessionQuery {
116 pub agent: Option<String>,
118 pub project: Option<String>,
120 pub status: Option<SessionStatus>,
122 pub limit: Option<usize>,
124 pub offset: Option<usize>,
126 pub sort: SortOrder,
128}
129
130impl SessionQuery {
131 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
138 self.agent = Some(agent.into());
139 self
140 }
141
142 pub fn with_project(mut self, project: impl Into<String>) -> Self {
144 self.project = Some(project.into());
145 self
146 }
147
148 pub fn with_status(mut self, status: SessionStatus) -> Self {
150 self.status = Some(status);
151 self
152 }
153
154 pub fn with_limit(mut self, limit: usize) -> Self {
156 self.limit = Some(limit);
157 self
158 }
159
160 pub fn with_offset(mut self, offset: usize) -> Self {
162 self.offset = Some(offset);
163 self
164 }
165
166 pub fn with_sort(mut self, sort: SortOrder) -> Self {
168 self.sort = sort;
169 self
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SessionSummary {
176 pub session_id: String,
178 pub agent: String,
180 pub project: Option<String>,
182 pub status: SessionStatus,
184 pub started_at: String,
186 pub finished_at: Option<String>,
188 pub turn_count: u32,
190 pub tool_call_count: u32,
192 pub input: String,
194 pub output: Option<String>,
196 pub file_path: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SessionDetails {
203 pub summary: SessionSummary,
205 pub events: Vec<SessionEvent>,
207}
208
209pub struct SessionReader {
211 agents_dir: PathBuf,
213 projects_dir: Option<PathBuf>,
215}
216
217impl SessionReader {
218 pub fn new(agents_dir: PathBuf, projects_dir: Option<PathBuf>) -> Self {
224 Self {
225 agents_dir,
226 projects_dir,
227 }
228 }
229
230 pub fn list(&self, query: &SessionQuery) -> Result<Vec<SessionSummary>> {
232 let mut summaries = Vec::new();
233
234 if self.agents_dir.exists() {
236 self.collect_sessions_from_agents(&mut summaries, query)?;
237 }
238
239 if let Some(ref projects_dir) = self.projects_dir {
241 if projects_dir.exists() {
242 self.collect_sessions_from_projects(&mut summaries, query)?;
243 }
244 }
245
246 summaries.sort_by(|a, b| a.session_id.cmp(&b.session_id));
248 summaries.dedup_by(|a, b| a.session_id == b.session_id);
249
250 match query.sort {
252 SortOrder::NewestFirst => {
253 summaries.sort_by(|a, b| b.started_at.cmp(&a.started_at));
254 }
255 SortOrder::OldestFirst => {
256 summaries.sort_by(|a, b| a.started_at.cmp(&b.started_at));
257 }
258 }
259
260 let offset = query.offset.unwrap_or(0);
262 let limit = query.limit.unwrap_or(usize::MAX);
263
264 let result: Vec<SessionSummary> = summaries.into_iter().skip(offset).take(limit).collect();
265
266 Ok(result)
267 }
268
269 pub fn get(&self, session_id: &str) -> Result<Option<SessionDetails>> {
271 if self.agents_dir.exists() {
273 if let Some(details) = self.find_session_in_agents(session_id)? {
274 return Ok(Some(details));
275 }
276 }
277
278 if let Some(ref projects_dir) = self.projects_dir {
280 if projects_dir.exists() {
281 if let Some(details) = self.find_session_in_projects(session_id)? {
282 return Ok(Some(details));
283 }
284 }
285 }
286
287 Ok(None)
288 }
289
290 fn collect_sessions_from_agents(
292 &self,
293 summaries: &mut Vec<SessionSummary>,
294 query: &SessionQuery,
295 ) -> Result<()> {
296 let entries = fs::read_dir(&self.agents_dir)
297 .with_context(|| format!("Failed to read agents directory: {:?}", self.agents_dir))?;
298
299 for entry in entries.filter_map(|e| e.ok()) {
300 let agent_dir = entry.path();
301 if !agent_dir.is_dir() {
302 continue;
303 }
304
305 let agent_name = agent_dir
306 .file_name()
307 .and_then(|n| n.to_str())
308 .unwrap_or("")
309 .to_string();
310
311 if let Some(ref filter_agent) = query.agent {
313 if &agent_name != filter_agent {
314 continue;
315 }
316 }
317
318 let sessions_dir = agent_dir.join("sessions");
319 if sessions_dir.exists() {
320 self.collect_sessions_from_dir(&sessions_dir, &agent_name, summaries, query)?;
321 }
322 }
323
324 Ok(())
325 }
326
327 fn collect_sessions_from_projects(
329 &self,
330 summaries: &mut Vec<SessionSummary>,
331 query: &SessionQuery,
332 ) -> Result<()> {
333 let projects_dir = match &self.projects_dir {
334 Some(d) => d,
335 None => return Ok(()),
336 };
337
338 let entries = fs::read_dir(projects_dir)
339 .with_context(|| format!("Failed to read projects directory: {:?}", projects_dir))?;
340
341 for entry in entries.filter_map(|e| e.ok()) {
342 let project_dir = entry.path();
343 if !project_dir.is_dir() {
344 continue;
345 }
346
347 let project_name = project_dir
348 .file_name()
349 .and_then(|n| n.to_str())
350 .unwrap_or("")
351 .to_string();
352
353 if let Some(ref filter_project) = query.project {
355 if &project_name != filter_project {
356 continue;
357 }
358 }
359
360 let sessions_dir = project_dir.join("sessions");
361 if sessions_dir.exists() {
362 self.collect_sessions_from_dir_with_project(
363 &sessions_dir,
364 &project_name,
365 summaries,
366 query,
367 )?;
368 }
369 }
370
371 Ok(())
372 }
373
374 fn collect_sessions_from_dir(
376 &self,
377 sessions_dir: &PathBuf,
378 agent_name: &str,
379 summaries: &mut Vec<SessionSummary>,
380 query: &SessionQuery,
381 ) -> Result<()> {
382 let entries = fs::read_dir(sessions_dir)
383 .with_context(|| format!("Failed to read sessions directory: {:?}", sessions_dir))?;
384
385 for entry in entries.filter_map(|e| e.ok()) {
386 let path = entry.path();
387 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
388 continue;
389 }
390
391 if let Ok(summary) = self.parse_session_file(&path, agent_name, None) {
392 if let Some(ref filter_status) = query.status {
394 if &summary.status != filter_status {
395 continue;
396 }
397 }
398 if let Some(ref filter_project) = query.project {
400 if summary.project.as_ref() != Some(filter_project) {
401 continue;
402 }
403 }
404 summaries.push(summary);
405 }
406 }
407
408 Ok(())
409 }
410
411 fn collect_sessions_from_dir_with_project(
413 &self,
414 sessions_dir: &PathBuf,
415 project_name: &str,
416 summaries: &mut Vec<SessionSummary>,
417 query: &SessionQuery,
418 ) -> Result<()> {
419 let entries = fs::read_dir(sessions_dir)
420 .with_context(|| format!("Failed to read sessions directory: {:?}", sessions_dir))?;
421
422 for entry in entries.filter_map(|e| e.ok()) {
423 let path = entry.path();
424 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
425 continue;
426 }
427
428 if let Ok(summary) = self.parse_session_file(&path, "", Some(project_name)) {
429 if let Some(ref filter_status) = query.status {
431 if &summary.status != filter_status {
432 continue;
433 }
434 }
435 summaries.push(summary);
436 }
437 }
438
439 Ok(())
440 }
441
442 fn parse_session_file(
444 &self,
445 path: &PathBuf,
446 default_agent: &str,
447 default_project: Option<&str>,
448 ) -> Result<SessionSummary> {
449 let file = fs::File::open(path)
450 .with_context(|| format!("Failed to open session file: {:?}", path))?;
451 let reader = BufReader::new(file);
452
453 let mut session_id = String::new();
454 let mut agent = default_agent.to_string();
455 let mut project = default_project.map(String::from);
456 let mut status = SessionStatus::Running;
457 let mut started_at = String::new();
458 let mut finished_at: Option<String> = None;
459 let mut turn_count = 0u32;
460 let mut tool_call_count = 0u32;
461 let mut input = String::new();
462 let mut output: Option<String> = None;
463
464 for line in reader.lines() {
465 let line = line.with_context(|| format!("Failed to read line from: {:?}", path))?;
466 if line.trim().is_empty() {
467 continue;
468 }
469
470 if let Ok(event) = serde_json::from_str::<SessionEvent>(&line) {
471 match event {
472 SessionEvent::Start {
473 session_id: sid,
474 agent: a,
475 project: p,
476 input: i,
477 started_at: s,
478 } => {
479 session_id = sid;
480 agent = a;
481 if p.is_some() {
482 project = p;
483 }
484 input = i;
485 started_at = s;
486 }
487 SessionEvent::Turn { .. } => {
488 turn_count += 1;
489 }
490 SessionEvent::ToolCall { .. } => {
491 tool_call_count += 1;
492 }
493 SessionEvent::ToolCallResult { .. } => {
494 }
496 SessionEvent::Step { .. } => {
497 tool_call_count += 1;
499 }
500 SessionEvent::Finish {
501 outcome,
502 output: o,
503 finished_at: f,
504 ..
505 } => {
506 status = SessionStatus::from_outcome(&outcome);
507 output = o;
508 finished_at = Some(f);
509 }
510 }
511 }
512 }
513
514 let file_path = path
516 .file_name()
517 .and_then(|n| n.to_str())
518 .unwrap_or("")
519 .to_string();
520
521 Ok(SessionSummary {
522 session_id,
523 agent,
524 project,
525 status,
526 started_at,
527 finished_at,
528 turn_count,
529 tool_call_count,
530 input,
531 output,
532 file_path,
533 })
534 }
535
536 fn find_session_in_agents(&self, session_id: &str) -> Result<Option<SessionDetails>> {
538 let entries = fs::read_dir(&self.agents_dir)
539 .with_context(|| format!("Failed to read agents directory: {:?}", self.agents_dir))?;
540
541 for entry in entries.filter_map(|e| e.ok()) {
542 let agent_dir = entry.path();
543 if !agent_dir.is_dir() {
544 continue;
545 }
546
547 let sessions_dir = agent_dir.join("sessions");
548 if !sessions_dir.exists() {
549 continue;
550 }
551
552 if let Some(details) = self.find_session_in_dir(&sessions_dir, session_id)? {
553 return Ok(Some(details));
554 }
555 }
556
557 Ok(None)
558 }
559
560 fn find_session_in_projects(&self, session_id: &str) -> Result<Option<SessionDetails>> {
562 let projects_dir = match &self.projects_dir {
563 Some(d) => d,
564 None => return Ok(None),
565 };
566
567 let entries = fs::read_dir(projects_dir)
568 .with_context(|| format!("Failed to read projects directory: {:?}", projects_dir))?;
569
570 for entry in entries.filter_map(|e| e.ok()) {
571 let project_dir = entry.path();
572 if !project_dir.is_dir() {
573 continue;
574 }
575
576 let sessions_dir = project_dir.join("sessions");
577 if !sessions_dir.exists() {
578 continue;
579 }
580
581 if let Some(details) = self.find_session_in_dir(&sessions_dir, session_id)? {
582 return Ok(Some(details));
583 }
584 }
585
586 Ok(None)
587 }
588
589 fn find_session_in_dir(
591 &self,
592 sessions_dir: &PathBuf,
593 session_id: &str,
594 ) -> Result<Option<SessionDetails>> {
595 let entries = fs::read_dir(sessions_dir)
596 .with_context(|| format!("Failed to read sessions directory: {:?}", sessions_dir))?;
597
598 for entry in entries.filter_map(|e| e.ok()) {
599 let path = entry.path();
600 if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
601 continue;
602 }
603
604 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
606 if !filename.contains(session_id) {
607 continue;
608 }
609
610 if let Ok(details) = self.parse_session_file_full(&path, session_id) {
611 if details.summary.session_id == session_id {
612 return Ok(Some(details));
613 }
614 }
615 }
616
617 Ok(None)
618 }
619
620 fn parse_session_file_full(&self, path: &PathBuf, _session_id: &str) -> Result<SessionDetails> {
622 let file = fs::File::open(path)
623 .with_context(|| format!("Failed to open session file: {:?}", path))?;
624 let reader = BufReader::new(file);
625
626 let mut events = Vec::new();
627 let mut session_id = String::new();
628 let mut agent = String::new();
629 let mut project: Option<String> = None;
630 let mut status = SessionStatus::Running;
631 let mut started_at = String::new();
632 let mut finished_at: Option<String> = None;
633 let mut turn_count = 0u32;
634 let mut tool_call_count = 0u32;
635 let mut input = String::new();
636 let mut output: Option<String> = None;
637
638 for line in reader.lines() {
639 let line = line.with_context(|| format!("Failed to read line from: {:?}", path))?;
640 if line.trim().is_empty() {
641 continue;
642 }
643
644 if let Ok(event) = serde_json::from_str::<SessionEvent>(&line) {
645 match &event {
646 SessionEvent::Start {
647 session_id: sid,
648 agent: a,
649 project: p,
650 input: i,
651 started_at: s,
652 } => {
653 session_id = sid.clone();
654 agent = a.clone();
655 project = p.clone();
656 input = i.clone();
657 started_at = s.clone();
658 }
659 SessionEvent::Turn { .. } => {
660 turn_count += 1;
661 }
662 SessionEvent::ToolCall { .. } => {
663 tool_call_count += 1;
664 }
665 SessionEvent::Step { .. } => {
666 tool_call_count += 1;
667 }
668 SessionEvent::Finish {
669 outcome,
670 output: o,
671 finished_at: f,
672 ..
673 } => {
674 status = SessionStatus::from_outcome(outcome);
675 output = o.clone();
676 finished_at = Some(f.clone());
677 }
678 _ => {}
679 }
680 events.push(event);
681 }
682 }
683
684 let file_path = path
685 .file_name()
686 .and_then(|n| n.to_str())
687 .unwrap_or("")
688 .to_string();
689
690 Ok(SessionDetails {
691 summary: SessionSummary {
692 session_id,
693 agent,
694 project,
695 status,
696 started_at,
697 finished_at,
698 turn_count,
699 tool_call_count,
700 input,
701 output,
702 file_path,
703 },
704 events,
705 })
706 }
707}
708
709pub struct SessionRecorder {
715 agent_sessions_dir: PathBuf,
716 project_sessions_dir: Option<PathBuf>,
717 agent: String,
718 project: Option<String>,
719 session_id: String,
720 filename: Option<String>,
722}
723
724impl SessionRecorder {
725 pub fn new(
727 agent_sessions_dir: PathBuf,
728 project_sessions_dir: Option<PathBuf>,
729 agent: String,
730 project: Option<String>,
731 session_id: String,
732 ) -> Self {
733 Self {
734 agent_sessions_dir,
735 project_sessions_dir,
736 agent,
737 project,
738 session_id,
739 filename: None,
740 }
741 }
742
743 fn append_line(path: &std::path::Path, line: &str) -> Result<()> {
745 if let Some(parent) = path.parent() {
746 std::fs::create_dir_all(parent).context("Failed to create sessions directory")?;
747 }
748 let mut f = OpenOptions::new()
749 .create(true)
750 .append(true)
751 .open(path)
752 .context("Failed to open session file")?;
753 writeln!(f, "{}", line).context("Failed to write session line")?;
754 f.sync_all().context("Failed to sync session file")?;
755 Ok(())
756 }
757
758 pub fn start(&mut self, input: &str) -> Result<()> {
760 let started_at = Utc::now().format("%Y-%m-%dT%H-%M-%SZ").to_string();
761 let filename = format!("{}_{}.jsonl", started_at, self.session_id);
762 self.filename = Some(filename.clone());
763
764 let path = self.agent_sessions_dir.join(&filename);
765 let event = SessionEvent::Start {
766 session_id: self.session_id.clone(),
767 agent: self.agent.clone(),
768 project: self.project.clone(),
769 input: input.to_string(),
770 started_at: Utc::now().to_rfc3339(),
771 };
772 let line = serde_json::to_string(&event).context("Failed to serialize start event")?;
773 Self::append_line(&path, &line)?;
774 Ok(())
775 }
776
777 pub fn turn(&self, turn: u32, input: &str, output: &str) -> Result<()> {
779 let filename = self.filename.as_deref().ok_or_else(|| {
780 anyhow::anyhow!("SessionRecorder: start() must be called before turn()")
781 })?;
782 let path = self.agent_sessions_dir.join(filename);
783 let event = SessionEvent::Turn {
784 session_id: self.session_id.clone(),
785 turn,
786 input: input.to_string(),
787 output: output.to_string(),
788 timestamp: Utc::now().to_rfc3339(),
789 };
790 let line = serde_json::to_string(&event).context("Failed to serialize turn event")?;
791 Self::append_line(&path, &line)?;
792 Ok(())
793 }
794
795 pub fn step(
797 &self,
798 step: u32,
799 tool: &str,
800 args: &serde_json::Value,
801 duration_ms: u64,
802 ) -> Result<()> {
803 let filename = self.filename.as_deref().ok_or_else(|| {
804 anyhow::anyhow!("SessionRecorder: start() must be called before step()")
805 })?;
806 let path = self.agent_sessions_dir.join(filename);
807 let event = SessionEvent::Step {
808 session_id: self.session_id.clone(),
809 step,
810 tool: tool.to_string(),
811 args: args.clone(),
812 duration_ms,
813 };
814 let line = serde_json::to_string(&event).context("Failed to serialize step event")?;
815 Self::append_line(&path, &line)?;
816 Ok(())
817 }
818
819 pub fn tool_call(
821 &self,
822 tool_call_id: &str,
823 tool: &str,
824 input: &serde_json::Value,
825 ) -> Result<()> {
826 let filename = self.filename.as_deref().ok_or_else(|| {
827 anyhow::anyhow!("SessionRecorder: start() must be called before tool_call()")
828 })?;
829 let path = self.agent_sessions_dir.join(filename);
830 let event = SessionEvent::ToolCall {
831 session_id: self.session_id.clone(),
832 tool_call_id: tool_call_id.to_string(),
833 tool: tool.to_string(),
834 input: input.clone(),
835 timestamp: Utc::now().to_rfc3339(),
836 };
837 let line = serde_json::to_string(&event).context("Failed to serialize tool_call event")?;
838 Self::append_line(&path, &line)?;
839 Ok(())
840 }
841
842 pub fn tool_call_result(
844 &self,
845 tool_call_id: &str,
846 tool: &str,
847 output: &serde_json::Value,
848 duration_ms: u64,
849 ) -> Result<()> {
850 let filename = self.filename.as_deref().ok_or_else(|| {
851 anyhow::anyhow!("SessionRecorder: start() must be called before tool_call_result()")
852 })?;
853 let path = self.agent_sessions_dir.join(filename);
854 let event = SessionEvent::ToolCallResult {
855 session_id: self.session_id.clone(),
856 tool_call_id: tool_call_id.to_string(),
857 tool: tool.to_string(),
858 output: output.clone(),
859 duration_ms,
860 timestamp: Utc::now().to_rfc3339(),
861 };
862 let line =
863 serde_json::to_string(&event).context("Failed to serialize tool_call_result event")?;
864 Self::append_line(&path, &line)?;
865 Ok(())
866 }
867
868 pub fn finish(&self, outcome: &str, output: Option<&str>, iterations: usize) -> Result<()> {
870 let filename = self.filename.as_deref().ok_or_else(|| {
871 anyhow::anyhow!("SessionRecorder: start() must be called before finish()")
872 })?;
873 let path = self.agent_sessions_dir.join(filename);
874 let event = SessionEvent::Finish {
875 session_id: self.session_id.clone(),
876 outcome: outcome.to_string(),
877 output: output.map(String::from),
878 iterations,
879 finished_at: Utc::now().to_rfc3339(),
880 };
881 let line = serde_json::to_string(&event).context("Failed to serialize finish event")?;
882 Self::append_line(&path, &line)?;
883
884 if let Some(ref project_dir) = self.project_sessions_dir {
885 let dest = project_dir.join(filename);
886 if path.exists() {
887 std::fs::copy(&path, &dest).context("Failed to copy session file to project")?;
888 }
889 }
890 Ok(())
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use tempfile::TempDir;
898
899 #[test]
900 fn session_event_start_serializes() {
901 let e = SessionEvent::Start {
902 session_id: "id1".to_string(),
903 agent: "a".to_string(),
904 project: None,
905 input: "hi".to_string(),
906 started_at: "2026-02-23T22:38:00Z".to_string(),
907 };
908 let j = serde_json::to_string(&e).unwrap();
909 assert!(j.contains("\"event\":\"start\""));
910 assert!(j.contains("\"session_id\":\"id1\""));
911 assert!(j.contains("\"input\":\"hi\""));
912 }
913
914 #[test]
915 fn session_event_turn_serializes() {
916 let e = SessionEvent::Turn {
917 session_id: "id1".to_string(),
918 turn: 1,
919 input: "Hello".to_string(),
920 output: "Hi there!".to_string(),
921 timestamp: "2026-02-23T22:38:00Z".to_string(),
922 };
923 let j = serde_json::to_string(&e).unwrap();
924 assert!(j.contains("\"event\":\"turn\""));
925 assert!(j.contains("\"turn\":1"));
926 assert!(j.contains("\"input\":\"Hello\""));
927 assert!(j.contains("\"output\":\"Hi there!\""));
928 }
929
930 #[test]
931 fn session_event_step_serializes() {
932 let e = SessionEvent::Step {
933 session_id: "id1".to_string(),
934 step: 1,
935 tool: "search".to_string(),
936 args: serde_json::json!({"query": "rust"}),
937 duration_ms: 150,
938 };
939 let j = serde_json::to_string(&e).unwrap();
940 assert!(j.contains("\"event\":\"step\""));
941 assert!(j.contains("\"step\":1"));
942 assert!(j.contains("\"tool\":\"search\""));
943 assert!(j.contains("\"duration_ms\":150"));
944 }
945
946 #[test]
947 fn session_event_tool_call_serializes() {
948 let e = SessionEvent::ToolCall {
949 session_id: "id1".to_string(),
950 tool_call_id: "call-123".to_string(),
951 tool: "shell".to_string(),
952 input: serde_json::json!({"command": "ls -la"}),
953 timestamp: "2026-02-25T12:00:00Z".to_string(),
954 };
955 let j = serde_json::to_string(&e).unwrap();
956 assert!(j.contains("\"event\":\"tool_call\""));
957 assert!(j.contains("\"tool_call_id\":\"call-123\""));
958 assert!(j.contains("\"tool\":\"shell\""));
959 assert!(j.contains("\"command\":\"ls -la\""));
960 }
961
962 #[test]
963 fn session_event_tool_call_result_serializes() {
964 let e = SessionEvent::ToolCallResult {
965 session_id: "id1".to_string(),
966 tool_call_id: "call-123".to_string(),
967 tool: "shell".to_string(),
968 output: serde_json::json!({"exit_code": 0, "stdout": "file.txt"}),
969 duration_ms: 50,
970 timestamp: "2026-02-25T12:00:01Z".to_string(),
971 };
972 let j = serde_json::to_string(&e).unwrap();
973 assert!(j.contains("\"event\":\"tool_call_result\""));
974 assert!(j.contains("\"tool_call_id\":\"call-123\""));
975 assert!(j.contains("\"tool\":\"shell\""));
976 assert!(j.contains("\"exit_code\":0"));
977 assert!(j.contains("\"duration_ms\":50"));
978 }
979
980 #[test]
981 fn session_event_finish_serializes() {
982 let e = SessionEvent::Finish {
983 session_id: "id1".to_string(),
984 outcome: "completed".to_string(),
985 output: Some("Done!".to_string()),
986 iterations: 3,
987 finished_at: "2026-02-23T22:40:00Z".to_string(),
988 };
989 let j = serde_json::to_string(&e).unwrap();
990 assert!(j.contains("\"event\":\"finish\""));
991 assert!(j.contains("\"outcome\":\"completed\""));
992 assert!(j.contains("\"iterations\":3"));
993 }
994
995 #[test]
996 fn session_recorder_start_creates_file() {
997 let tmp = TempDir::new().unwrap();
998 let sessions_dir = tmp.path().join("sessions");
999
1000 let mut recorder = SessionRecorder::new(
1001 sessions_dir.clone(),
1002 None,
1003 "test-agent".to_string(),
1004 None,
1005 "session-123".to_string(),
1006 );
1007
1008 recorder.start("Hello world").unwrap();
1009
1010 let files: Vec<_> = std::fs::read_dir(&sessions_dir)
1012 .unwrap()
1013 .filter_map(|e| e.ok())
1014 .collect();
1015 assert_eq!(files.len(), 1);
1016
1017 let content = std::fs::read_to_string(files[0].path()).unwrap();
1019 assert!(content.contains("\"event\":\"start\""));
1020 assert!(content.contains("Hello world"));
1021 }
1022
1023 #[test]
1024 fn session_recorder_turn_records_input_and_output() {
1025 let tmp = TempDir::new().unwrap();
1026 let sessions_dir = tmp.path().join("sessions");
1027
1028 let mut recorder = SessionRecorder::new(
1029 sessions_dir.clone(),
1030 None,
1031 "test-agent".to_string(),
1032 None,
1033 "session-456".to_string(),
1034 );
1035
1036 recorder.start("Session start").unwrap();
1037 recorder
1038 .turn(1, "User message", "Assistant response")
1039 .unwrap();
1040
1041 let files: Vec<_> = std::fs::read_dir(&sessions_dir)
1043 .unwrap()
1044 .filter_map(|e| e.ok())
1045 .collect();
1046 let content = std::fs::read_to_string(files[0].path()).unwrap();
1047 let lines: Vec<&str> = content.lines().collect();
1048
1049 assert_eq!(lines.len(), 2);
1050 assert!(lines[0].contains("\"event\":\"start\""));
1051 assert!(lines[1].contains("\"event\":\"turn\""));
1052 assert!(lines[1].contains("\"input\":\"User message\""));
1053 assert!(lines[1].contains("\"output\":\"Assistant response\""));
1054 }
1055
1056 #[test]
1057 fn session_recorder_step_records_tool_calls() {
1058 let tmp = TempDir::new().unwrap();
1059 let sessions_dir = tmp.path().join("sessions");
1060
1061 let mut recorder = SessionRecorder::new(
1062 sessions_dir.clone(),
1063 None,
1064 "test-agent".to_string(),
1065 None,
1066 "session-789".to_string(),
1067 );
1068
1069 recorder.start("Session start").unwrap();
1070 recorder
1071 .step(
1072 1,
1073 "search",
1074 &serde_json::json!({"query": "rust async"}),
1075 250,
1076 )
1077 .unwrap();
1078 recorder
1079 .step(
1080 2,
1081 "read_file",
1082 &serde_json::json!({"path": "/tmp/test.rs"}),
1083 50,
1084 )
1085 .unwrap();
1086
1087 let files: Vec<_> = std::fs::read_dir(&sessions_dir)
1089 .unwrap()
1090 .filter_map(|e| e.ok())
1091 .collect();
1092 let content = std::fs::read_to_string(files[0].path()).unwrap();
1093 let lines: Vec<&str> = content.lines().collect();
1094
1095 assert_eq!(lines.len(), 3);
1096 assert!(lines[1].contains("\"tool\":\"search\""));
1097 assert!(lines[1].contains("\"duration_ms\":250"));
1098 assert!(lines[2].contains("\"tool\":\"read_file\""));
1099 }
1100
1101 #[test]
1102 fn session_recorder_full_flow() {
1103 let tmp = TempDir::new().unwrap();
1104 let sessions_dir = tmp.path().join("sessions");
1105
1106 let mut recorder = SessionRecorder::new(
1107 sessions_dir.clone(),
1108 None,
1109 "test-agent".to_string(),
1110 None,
1111 "session-full".to_string(),
1112 );
1113
1114 recorder.start("Conversation started").unwrap();
1116 recorder
1117 .step(1, "search", &serde_json::json!({"q": "test"}), 100)
1118 .unwrap();
1119 recorder
1120 .turn(1, "Find info about X", "Here's what I found...")
1121 .unwrap();
1122 recorder
1123 .step(2, "read_file", &serde_json::json!({"path": "x.rs"}), 50)
1124 .unwrap();
1125 recorder
1126 .turn(2, "What does the file say?", "The file contains...")
1127 .unwrap();
1128 recorder
1129 .finish("completed", Some("Session done"), 2)
1130 .unwrap();
1131
1132 let files: Vec<_> = std::fs::read_dir(&sessions_dir)
1134 .unwrap()
1135 .filter_map(|e| e.ok())
1136 .collect();
1137 let content = std::fs::read_to_string(files[0].path()).unwrap();
1138 let lines: Vec<&str> = content.lines().collect();
1139
1140 assert_eq!(lines.len(), 6);
1141 assert!(lines[0].contains("\"event\":\"start\""));
1142 assert!(lines[1].contains("\"event\":\"step\""));
1143 assert!(lines[2].contains("\"event\":\"turn\""));
1144 assert!(lines[3].contains("\"event\":\"step\""));
1145 assert!(lines[4].contains("\"event\":\"turn\""));
1146 assert!(lines[5].contains("\"event\":\"finish\""));
1147 }
1148
1149 #[test]
1150 fn session_recorder_turn_without_start_fails() {
1151 let tmp = TempDir::new().unwrap();
1152 let sessions_dir = tmp.path().join("sessions");
1153
1154 let recorder = SessionRecorder::new(
1155 sessions_dir,
1156 None,
1157 "test-agent".to_string(),
1158 None,
1159 "session-nostart".to_string(),
1160 );
1161
1162 let result = recorder.turn(1, "input", "output");
1163 assert!(result.is_err());
1164 assert!(result
1165 .unwrap_err()
1166 .to_string()
1167 .contains("start() must be called"));
1168 }
1169
1170 #[test]
1171 fn session_recorder_step_without_start_fails() {
1172 let tmp = TempDir::new().unwrap();
1173 let sessions_dir = tmp.path().join("sessions");
1174
1175 let recorder = SessionRecorder::new(
1176 sessions_dir,
1177 None,
1178 "test-agent".to_string(),
1179 None,
1180 "session-nostart".to_string(),
1181 );
1182
1183 let result = recorder.step(1, "tool", &serde_json::json!({}), 100);
1184 assert!(result.is_err());
1185 assert!(result
1186 .unwrap_err()
1187 .to_string()
1188 .contains("start() must be called"));
1189 }
1190
1191 #[test]
1196 fn session_status_from_outcome() {
1197 assert_eq!(
1198 SessionStatus::from_outcome("completed"),
1199 SessionStatus::Completed
1200 );
1201 assert_eq!(
1202 SessionStatus::from_outcome("COMPLETED"),
1203 SessionStatus::Completed
1204 );
1205 assert_eq!(
1206 SessionStatus::from_outcome("success"),
1207 SessionStatus::Completed
1208 );
1209 assert_eq!(SessionStatus::from_outcome("failed"), SessionStatus::Failed);
1210 assert_eq!(SessionStatus::from_outcome("error"), SessionStatus::Failed);
1211 assert_eq!(
1212 SessionStatus::from_outcome("cancelled"),
1213 SessionStatus::Cancelled
1214 );
1215 assert_eq!(
1216 SessionStatus::from_outcome("canceled"),
1217 SessionStatus::Cancelled
1218 );
1219 assert_eq!(
1221 SessionStatus::from_outcome("unknown"),
1222 SessionStatus::Completed
1223 );
1224 }
1225
1226 #[test]
1227 fn session_query_builder() {
1228 let query = SessionQuery::new()
1229 .with_agent("test-agent")
1230 .with_project("test-project")
1231 .with_status(SessionStatus::Completed)
1232 .with_limit(10)
1233 .with_offset(5)
1234 .with_sort(SortOrder::OldestFirst);
1235
1236 assert_eq!(query.agent, Some("test-agent".to_string()));
1237 assert_eq!(query.project, Some("test-project".to_string()));
1238 assert_eq!(query.status, Some(SessionStatus::Completed));
1239 assert_eq!(query.limit, Some(10));
1240 assert_eq!(query.offset, Some(5));
1241 assert_eq!(query.sort, SortOrder::OldestFirst);
1242 }
1243
1244 #[test]
1245 fn session_reader_empty_directory() {
1246 let tmp = TempDir::new().unwrap();
1247 let agents_dir = tmp.path().join("agents");
1248 std::fs::create_dir_all(&agents_dir).unwrap();
1249
1250 let reader = SessionReader::new(agents_dir, None);
1251 let sessions = reader.list(&SessionQuery::new()).unwrap();
1252
1253 assert!(sessions.is_empty());
1254 }
1255
1256 #[test]
1257 fn session_reader_lists_sessions() {
1258 let tmp = TempDir::new().unwrap();
1259 let agents_dir = tmp.path().join("agents");
1260 let sessions_dir = agents_dir.join("test-agent").join("sessions");
1261 std::fs::create_dir_all(&sessions_dir).unwrap();
1262
1263 let mut recorder = SessionRecorder::new(
1265 sessions_dir.clone(),
1266 None,
1267 "test-agent".to_string(),
1268 None,
1269 "session-list-test".to_string(),
1270 );
1271 recorder.start("Hello world").unwrap();
1272 recorder.turn(1, "Hello", "Hi there!").unwrap();
1273 recorder.finish("completed", Some("Done"), 1).unwrap();
1274
1275 let reader = SessionReader::new(agents_dir, None);
1277 let sessions = reader.list(&SessionQuery::new()).unwrap();
1278
1279 assert_eq!(sessions.len(), 1);
1280 assert_eq!(sessions[0].session_id, "session-list-test");
1281 assert_eq!(sessions[0].agent, "test-agent");
1282 assert_eq!(sessions[0].status, SessionStatus::Completed);
1283 assert_eq!(sessions[0].turn_count, 1);
1284 }
1285
1286 #[test]
1287 fn session_reader_filters_by_agent() {
1288 let tmp = TempDir::new().unwrap();
1289 let agents_dir = tmp.path().join("agents");
1290
1291 for agent in ["agent1", "agent2"] {
1293 let sessions_dir = agents_dir.join(agent).join("sessions");
1294 std::fs::create_dir_all(&sessions_dir).unwrap();
1295
1296 let mut recorder = SessionRecorder::new(
1297 sessions_dir,
1298 None,
1299 agent.to_string(),
1300 None,
1301 format!("session-{}", agent),
1302 );
1303 recorder.start("Test").unwrap();
1304 recorder.finish("completed", None, 0).unwrap();
1305 }
1306
1307 let reader = SessionReader::new(agents_dir, None);
1308
1309 let sessions = reader
1311 .list(&SessionQuery::new().with_agent("agent1"))
1312 .unwrap();
1313 assert_eq!(sessions.len(), 1);
1314 assert_eq!(sessions[0].agent, "agent1");
1315
1316 let all_sessions = reader.list(&SessionQuery::new()).unwrap();
1318 assert_eq!(all_sessions.len(), 2);
1319 }
1320
1321 #[test]
1322 fn session_reader_filters_by_status() {
1323 let tmp = TempDir::new().unwrap();
1324 let agents_dir = tmp.path().join("agents");
1325 let sessions_dir = agents_dir.join("test-agent").join("sessions");
1326 std::fs::create_dir_all(&sessions_dir).unwrap();
1327
1328 let mut recorder = SessionRecorder::new(
1330 sessions_dir.clone(),
1331 None,
1332 "test-agent".to_string(),
1333 None,
1334 "completed-session".to_string(),
1335 );
1336 recorder.start("Test 1").unwrap();
1337 recorder.finish("completed", None, 0).unwrap();
1338
1339 let mut recorder = SessionRecorder::new(
1341 sessions_dir.clone(),
1342 None,
1343 "test-agent".to_string(),
1344 None,
1345 "failed-session".to_string(),
1346 );
1347 recorder.start("Test 2").unwrap();
1348 recorder.finish("failed", None, 0).unwrap();
1349
1350 let reader = SessionReader::new(agents_dir, None);
1351
1352 let completed = reader
1354 .list(&SessionQuery::new().with_status(SessionStatus::Completed))
1355 .unwrap();
1356 assert_eq!(completed.len(), 1);
1357 assert_eq!(completed[0].status, SessionStatus::Completed);
1358
1359 let failed = reader
1361 .list(&SessionQuery::new().with_status(SessionStatus::Failed))
1362 .unwrap();
1363 assert_eq!(failed.len(), 1);
1364 assert_eq!(failed[0].status, SessionStatus::Failed);
1365 }
1366
1367 #[test]
1368 fn session_reader_get_session_details() {
1369 let tmp = TempDir::new().unwrap();
1370 let agents_dir = tmp.path().join("agents");
1371 let sessions_dir = agents_dir.join("test-agent").join("sessions");
1372 std::fs::create_dir_all(&sessions_dir).unwrap();
1373
1374 let mut recorder = SessionRecorder::new(
1375 sessions_dir,
1376 None,
1377 "test-agent".to_string(),
1378 None,
1379 "detail-test".to_string(),
1380 );
1381 recorder.start("Initial prompt").unwrap();
1382 recorder.turn(1, "User input", "Assistant output").unwrap();
1383 recorder
1384 .step(1, "shell", &serde_json::json!({"cmd": "ls"}), 50)
1385 .unwrap();
1386 recorder
1387 .finish("completed", Some("Final output"), 1)
1388 .unwrap();
1389
1390 let reader = SessionReader::new(agents_dir, None);
1391 let details = reader.get("detail-test").unwrap();
1392
1393 assert!(details.is_some());
1394 let details = details.unwrap();
1395 assert_eq!(details.summary.session_id, "detail-test");
1396 assert_eq!(details.summary.input, "Initial prompt");
1397 assert_eq!(details.summary.output, Some("Final output".to_string()));
1398 assert_eq!(details.events.len(), 4); }
1400
1401 #[test]
1402 fn session_reader_pagination() {
1403 let tmp = TempDir::new().unwrap();
1404 let agents_dir = tmp.path().join("agents");
1405 let sessions_dir = agents_dir.join("test-agent").join("sessions");
1406 std::fs::create_dir_all(&sessions_dir).unwrap();
1407
1408 for i in 0..5 {
1410 let mut recorder = SessionRecorder::new(
1411 sessions_dir.clone(),
1412 None,
1413 "test-agent".to_string(),
1414 None,
1415 format!("session-{}", i),
1416 );
1417 recorder.start(&format!("Test {}", i)).unwrap();
1418 recorder.finish("completed", None, 0).unwrap();
1419 }
1420
1421 let reader = SessionReader::new(agents_dir, None);
1422
1423 let page1 = reader
1425 .list(&SessionQuery::new().with_limit(2).with_offset(0))
1426 .unwrap();
1427 assert_eq!(page1.len(), 2);
1428
1429 let page2 = reader
1431 .list(&SessionQuery::new().with_limit(2).with_offset(2))
1432 .unwrap();
1433 assert_eq!(page2.len(), 2);
1434
1435 let page3 = reader
1437 .list(&SessionQuery::new().with_limit(2).with_offset(4))
1438 .unwrap();
1439 assert_eq!(page3.len(), 1);
1440 }
1441
1442 #[test]
1443 fn session_summary_serialization() {
1444 let summary = SessionSummary {
1445 session_id: "test-123".to_string(),
1446 agent: "test-agent".to_string(),
1447 project: Some("test-project".to_string()),
1448 status: SessionStatus::Completed,
1449 started_at: "2026-02-26T12:00:00Z".to_string(),
1450 finished_at: Some("2026-02-26T12:05:00Z".to_string()),
1451 turn_count: 5,
1452 tool_call_count: 3,
1453 input: "Hello".to_string(),
1454 output: Some("Goodbye".to_string()),
1455 file_path: "2026-02-26T12-00-00Z_test-123.jsonl".to_string(),
1456 };
1457
1458 let json = serde_json::to_string(&summary).unwrap();
1459 let parsed: SessionSummary = serde_json::from_str(&json).unwrap();
1460
1461 assert_eq!(parsed.session_id, "test-123");
1462 assert_eq!(parsed.status, SessionStatus::Completed);
1463 assert_eq!(parsed.turn_count, 5);
1464 }
1465}