Skip to main content

enact_runner/
session.rs

1//! Session recorder and reader — one JSONL file per run under agents/<name>/sessions/ and optionally projects/<slug>/sessions/.
2//!
3//! This module provides:
4//! - `SessionRecorder`: Write session events to JSONL files during execution
5//! - `SessionReader`: Query and read session data from JSONL files
6//! - `SessionQuery`: Filter and sort options for listing sessions
7//! - `SessionSummary`: Summary info about a session (from start/finish events)
8
9use 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/// One JSONL event (start, step, or finish).
17#[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// =============================================================================
74// Session Reading Infrastructure
75// =============================================================================
76
77/// Session status (derived from finish event outcome)
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum SessionStatus {
81    /// Session is currently running (no finish event yet)
82    Running,
83    /// Session completed successfully
84    Completed,
85    /// Session failed with an error
86    Failed,
87    /// Session was cancelled
88    Cancelled,
89}
90
91impl SessionStatus {
92    /// Parse status from outcome string in finish event
93    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, // Default to completed for unknown outcomes
99        }
100    }
101}
102
103/// Sort order for session listing
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum SortOrder {
106    /// Most recent sessions first (default)
107    #[default]
108    NewestFirst,
109    /// Oldest sessions first
110    OldestFirst,
111}
112
113/// Query parameters for listing sessions
114#[derive(Debug, Clone, Default)]
115pub struct SessionQuery {
116    /// Filter by agent name
117    pub agent: Option<String>,
118    /// Filter by project slug
119    pub project: Option<String>,
120    /// Filter by session status
121    pub status: Option<SessionStatus>,
122    /// Maximum number of results to return
123    pub limit: Option<usize>,
124    /// Number of results to skip (for pagination)
125    pub offset: Option<usize>,
126    /// Sort order
127    pub sort: SortOrder,
128}
129
130impl SessionQuery {
131    /// Create a new empty query (matches all sessions)
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Filter by agent name
137    pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
138        self.agent = Some(agent.into());
139        self
140    }
141
142    /// Filter by project
143    pub fn with_project(mut self, project: impl Into<String>) -> Self {
144        self.project = Some(project.into());
145        self
146    }
147
148    /// Filter by status
149    pub fn with_status(mut self, status: SessionStatus) -> Self {
150        self.status = Some(status);
151        self
152    }
153
154    /// Set result limit
155    pub fn with_limit(mut self, limit: usize) -> Self {
156        self.limit = Some(limit);
157        self
158    }
159
160    /// Set offset for pagination
161    pub fn with_offset(mut self, offset: usize) -> Self {
162        self.offset = Some(offset);
163        self
164    }
165
166    /// Set sort order
167    pub fn with_sort(mut self, sort: SortOrder) -> Self {
168        self.sort = sort;
169        self
170    }
171}
172
173/// Summary information about a session
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SessionSummary {
176    /// Unique session identifier
177    pub session_id: String,
178    /// Agent name that ran this session
179    pub agent: String,
180    /// Project this session belongs to (if any)
181    pub project: Option<String>,
182    /// Current session status
183    pub status: SessionStatus,
184    /// When the session started (ISO 8601)
185    pub started_at: String,
186    /// When the session finished (ISO 8601), None if still running
187    pub finished_at: Option<String>,
188    /// Number of conversation turns
189    pub turn_count: u32,
190    /// Number of tool calls made
191    pub tool_call_count: u32,
192    /// Initial input/prompt
193    pub input: String,
194    /// Final output (if completed)
195    pub output: Option<String>,
196    /// Session file path (relative to ENACT_HOME)
197    pub file_path: String,
198}
199
200/// Detailed session information including all events
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SessionDetails {
203    /// Summary information
204    pub summary: SessionSummary,
205    /// All events in the session
206    pub events: Vec<SessionEvent>,
207}
208
209/// Reads session data from JSONL files
210pub struct SessionReader {
211    /// Directory containing agent sessions (e.g., ENACT_HOME/agents)
212    agents_dir: PathBuf,
213    /// Directory containing project sessions (e.g., ENACT_HOME/projects)
214    projects_dir: Option<PathBuf>,
215}
216
217impl SessionReader {
218    /// Create a new SessionReader
219    ///
220    /// # Arguments
221    /// * `agents_dir` - Path to agents directory (contains <agent>/sessions/*.jsonl)
222    /// * `projects_dir` - Optional path to projects directory
223    pub fn new(agents_dir: PathBuf, projects_dir: Option<PathBuf>) -> Self {
224        Self {
225            agents_dir,
226            projects_dir,
227        }
228    }
229
230    /// List sessions matching the query
231    pub fn list(&self, query: &SessionQuery) -> Result<Vec<SessionSummary>> {
232        let mut summaries = Vec::new();
233
234        // Collect sessions from agents directory
235        if self.agents_dir.exists() {
236            self.collect_sessions_from_agents(&mut summaries, query)?;
237        }
238
239        // Optionally collect from projects directory
240        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        // Deduplicate by session_id (in case same session appears in both)
247        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        // Sort by started_at timestamp
251        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        // Apply pagination
261        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    /// Get detailed session information by session ID
270    pub fn get(&self, session_id: &str) -> Result<Option<SessionDetails>> {
271        // Search in agents directory
272        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        // Search in projects directory
279        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    /// Collect sessions from agents/<name>/sessions/*.jsonl
291    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            // Filter by agent if specified
312            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    /// Collect sessions from projects/<slug>/sessions/*.jsonl
328    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            // Filter by project if specified
354            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    /// Collect sessions from a specific sessions directory
375    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                // Apply status filter
393                if let Some(ref filter_status) = query.status {
394                    if &summary.status != filter_status {
395                        continue;
396                    }
397                }
398                // Apply project filter
399                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    /// Collect sessions from a project sessions directory
412    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                // Apply status filter
430                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    /// Parse a session JSONL file and extract summary
443    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                        // Already counted in ToolCall
495                    }
496                    SessionEvent::Step { .. } => {
497                        // Legacy step events, count as tool calls
498                        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        // Build file_path relative to ENACT_HOME
515        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    /// Find a session by ID in the agents directory
537    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    /// Find a session by ID in the projects directory
561    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    /// Find a session by ID in a specific sessions directory
590    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            // Quick check: filename should contain the session_id
605            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    /// Parse a session file and return full details
621    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
709// =============================================================================
710// SessionRecorder (existing implementation)
711// =============================================================================
712
713/// Writes one JSONL file per run. Call start() then zero or more step() then finish().
714pub 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    /// Set on start(); used for step/finish and for copy to project dir.
721    filename: Option<String>,
722}
723
724impl SessionRecorder {
725    /// Create a new recorder. Paths should be ENACT_HOME/agents/<name>/sessions and optionally ENACT_HOME/projects/<slug>/sessions.
726    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    /// Append one JSON line to path. Creates parent dirs and file if needed.
744    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    /// Record session start. Call once at the beginning of a run.
759    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    /// Record one conversation turn (user input + assistant output).
778    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    /// Record one tool step (legacy, for backwards compatibility).
796    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    /// Record a tool call (input/arguments before execution).
820    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    /// Record a tool call result (output after execution).
843    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    /// Record session finish. Copies the session file to project_sessions_dir if set.
869    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        // Verify file was created
1011        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        // Verify content
1018        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        // Read the session file
1042        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        // Read the session file
1088        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        // Full session flow: start -> step -> turn -> step -> turn -> finish
1115        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        // Read and verify
1133        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    // =========================================================================
1192    // SessionReader Tests
1193    // =========================================================================
1194
1195    #[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        // Unknown defaults to Completed
1220        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        // Create a session file
1264        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        // Read sessions
1276        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        // Create sessions for two agents
1292        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        // Filter by agent1
1310        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        // All sessions
1317        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        // Create completed session
1329        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        // Create failed session
1340        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        // Filter by completed
1353        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        // Filter by failed
1360        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); // start, turn, step, finish
1399    }
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        // Create 5 sessions
1409        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        // First page
1424        let page1 = reader
1425            .list(&SessionQuery::new().with_limit(2).with_offset(0))
1426            .unwrap();
1427        assert_eq!(page1.len(), 2);
1428
1429        // Second page
1430        let page2 = reader
1431            .list(&SessionQuery::new().with_limit(2).with_offset(2))
1432            .unwrap();
1433        assert_eq!(page2.len(), 2);
1434
1435        // Last page
1436        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}