Skip to main content

claude_agent/session/
types.rs

1//! Session-related types for persistence and tracking.
2
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::state::{MessageId, SessionId};
10
11/// Environment context for coding-mode sessions.
12#[derive(Clone, Debug, Default, Serialize, Deserialize)]
13pub struct EnvironmentContext {
14    pub cwd: Option<PathBuf>,
15    pub git_branch: Option<String>,
16    pub git_commit: Option<String>,
17    pub platform: Option<String>,
18    pub sdk_version: Option<String>,
19}
20
21impl EnvironmentContext {
22    pub fn capture(working_dir: Option<&Path>) -> Self {
23        let (git_branch, git_commit) = working_dir.map(Self::git_info).unwrap_or_default();
24
25        Self {
26            cwd: working_dir.map(|p| p.to_path_buf()),
27            git_branch,
28            git_commit,
29            platform: Some(current_platform().to_string()),
30            sdk_version: Some(env!("CARGO_PKG_VERSION").to_string()),
31        }
32    }
33
34    fn git_info(dir: &Path) -> (Option<String>, Option<String>) {
35        let branch = std::process::Command::new("git")
36            .args(["rev-parse", "--abbrev-ref", "HEAD"])
37            .current_dir(dir)
38            .output()
39            .ok()
40            .filter(|o| o.status.success())
41            .and_then(|o| String::from_utf8(o.stdout).ok())
42            .map(|s| s.trim().to_string())
43            .filter(|s| !s.is_empty());
44
45        let commit = std::process::Command::new("git")
46            .args(["rev-parse", "--short", "HEAD"])
47            .current_dir(dir)
48            .output()
49            .ok()
50            .filter(|o| o.status.success())
51            .and_then(|o| String::from_utf8(o.stdout).ok())
52            .map(|s| s.trim().to_string())
53            .filter(|s| !s.is_empty());
54
55        (branch, commit)
56    }
57
58    pub fn is_empty(&self) -> bool {
59        self.cwd.is_none() && self.git_branch.is_none()
60    }
61}
62
63fn current_platform() -> &'static str {
64    if cfg!(target_os = "macos") {
65        "darwin"
66    } else if cfg!(target_os = "linux") {
67        "linux"
68    } else if cfg!(target_os = "windows") {
69        "windows"
70    } else {
71        "unknown"
72    }
73}
74
75/// Tool execution record.
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct ToolExecution {
78    pub id: Uuid,
79    pub session_id: SessionId,
80    pub message_id: Option<String>,
81    pub tool_name: String,
82    pub tool_input: serde_json::Value,
83    pub tool_output: String,
84    pub is_error: bool,
85    pub error_message: Option<String>,
86    pub duration_ms: u64,
87    pub input_tokens: Option<u32>,
88    pub output_tokens: Option<u32>,
89    pub plan_id: Option<Uuid>,
90    pub spawned_session_id: Option<SessionId>,
91    pub created_at: DateTime<Utc>,
92}
93
94impl ToolExecution {
95    pub fn new(
96        session_id: SessionId,
97        tool_name: impl Into<String>,
98        tool_input: serde_json::Value,
99    ) -> Self {
100        Self {
101            id: Uuid::new_v4(),
102            session_id,
103            message_id: None,
104            tool_name: tool_name.into(),
105            tool_input,
106            tool_output: String::new(),
107            is_error: false,
108            error_message: None,
109            duration_ms: 0,
110            input_tokens: None,
111            output_tokens: None,
112            plan_id: None,
113            spawned_session_id: None,
114            created_at: Utc::now(),
115        }
116    }
117
118    pub fn with_output(mut self, output: impl Into<String>, is_error: bool) -> Self {
119        self.tool_output = output.into();
120        self.is_error = is_error;
121        self
122    }
123
124    pub fn with_error(mut self, message: impl Into<String>) -> Self {
125        self.is_error = true;
126        self.error_message = Some(message.into());
127        self
128    }
129
130    pub fn with_duration(mut self, duration_ms: u64) -> Self {
131        self.duration_ms = duration_ms;
132        self
133    }
134
135    pub fn with_plan(mut self, plan_id: Uuid) -> Self {
136        self.plan_id = Some(plan_id);
137        self
138    }
139
140    pub fn with_spawned_session(mut self, session_id: SessionId) -> Self {
141        self.spawned_session_id = Some(session_id);
142        self
143    }
144
145    pub fn with_message(mut self, message_id: impl Into<String>) -> Self {
146        self.message_id = Some(message_id.into());
147        self
148    }
149
150    pub fn with_tokens(mut self, input: u32, output: u32) -> Self {
151        self.input_tokens = Some(input);
152        self.output_tokens = Some(output);
153        self
154    }
155}
156
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum PlanStatus {
160    #[default]
161    Draft,
162    Approved,
163    Executing,
164    Completed,
165    Failed,
166    Cancelled,
167}
168
169impl PlanStatus {
170    pub fn is_terminal(&self) -> bool {
171        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
172    }
173
174    pub fn can_execute(&self) -> bool {
175        matches!(self, Self::Approved)
176    }
177
178    /// Parse from string with legacy alias support.
179    pub fn from_str_lenient(s: &str) -> Self {
180        match s.to_lowercase().as_str() {
181            "approved" => Self::Approved,
182            "executing" | "inprogress" | "in_progress" => Self::Executing,
183            "completed" => Self::Completed,
184            "cancelled" | "canceled" => Self::Cancelled,
185            "failed" => Self::Failed,
186            _ => Self::Draft,
187        }
188    }
189}
190
191#[derive(Clone, Debug, Serialize, Deserialize)]
192pub struct Plan {
193    pub id: Uuid,
194    pub session_id: SessionId,
195    pub name: Option<String>,
196    pub content: String,
197    pub status: PlanStatus,
198    pub error: Option<String>,
199    pub created_at: DateTime<Utc>,
200    pub approved_at: Option<DateTime<Utc>>,
201    pub started_at: Option<DateTime<Utc>>,
202    pub completed_at: Option<DateTime<Utc>>,
203}
204
205impl Plan {
206    pub fn new(session_id: SessionId) -> Self {
207        Self {
208            id: Uuid::new_v4(),
209            session_id,
210            name: None,
211            content: String::new(),
212            status: PlanStatus::Draft,
213            error: None,
214            created_at: Utc::now(),
215            approved_at: None,
216            started_at: None,
217            completed_at: None,
218        }
219    }
220
221    pub fn with_name(mut self, name: impl Into<String>) -> Self {
222        self.name = Some(name.into());
223        self
224    }
225
226    pub fn with_content(mut self, content: impl Into<String>) -> Self {
227        self.content = content.into();
228        self
229    }
230
231    pub fn approve(&mut self) {
232        self.status = PlanStatus::Approved;
233        self.approved_at = Some(Utc::now());
234    }
235
236    pub fn start_execution(&mut self) {
237        self.status = PlanStatus::Executing;
238        self.started_at = Some(Utc::now());
239    }
240
241    pub fn complete(&mut self) {
242        self.status = PlanStatus::Completed;
243        self.completed_at = Some(Utc::now());
244    }
245
246    pub fn fail(&mut self, error: impl Into<String>) {
247        self.status = PlanStatus::Failed;
248        self.completed_at = Some(Utc::now());
249        self.error = Some(error.into());
250    }
251
252    pub fn cancel(&mut self) {
253        self.status = PlanStatus::Cancelled;
254        self.completed_at = Some(Utc::now());
255    }
256}
257
258#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum TodoStatus {
261    #[default]
262    Pending,
263    InProgress,
264    Completed,
265}
266
267impl TodoStatus {
268    /// Parse from string with legacy alias support.
269    pub fn from_str_lenient(s: &str) -> Self {
270        match s.to_lowercase().as_str() {
271            "in_progress" | "inprogress" => Self::InProgress,
272            "completed" | "done" => Self::Completed,
273            _ => Self::Pending,
274        }
275    }
276}
277
278#[derive(Clone, Debug, Serialize, Deserialize)]
279pub struct TodoItem {
280    pub id: Uuid,
281    pub session_id: SessionId,
282    pub content: String,
283    pub active_form: String,
284    pub status: TodoStatus,
285    pub plan_id: Option<Uuid>,
286    pub created_at: DateTime<Utc>,
287    pub started_at: Option<DateTime<Utc>>,
288    pub completed_at: Option<DateTime<Utc>>,
289}
290
291impl TodoItem {
292    pub fn new(
293        session_id: SessionId,
294        content: impl Into<String>,
295        active_form: impl Into<String>,
296    ) -> Self {
297        Self {
298            id: Uuid::new_v4(),
299            session_id,
300            content: content.into(),
301            active_form: active_form.into(),
302            status: TodoStatus::Pending,
303            plan_id: None,
304            created_at: Utc::now(),
305            started_at: None,
306            completed_at: None,
307        }
308    }
309
310    pub fn with_plan(mut self, plan_id: Uuid) -> Self {
311        self.plan_id = Some(plan_id);
312        self
313    }
314
315    pub fn start(&mut self) {
316        self.status = TodoStatus::InProgress;
317        self.started_at = Some(Utc::now());
318    }
319
320    pub fn complete(&mut self) {
321        self.status = TodoStatus::Completed;
322        self.completed_at = Some(Utc::now());
323    }
324
325    pub fn status_icon(&self) -> &'static str {
326        match self.status {
327            TodoStatus::Pending => "○",
328            TodoStatus::InProgress => "◐",
329            TodoStatus::Completed => "●",
330        }
331    }
332}
333
334#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum CompactTrigger {
337    #[default]
338    Manual,
339    Auto,
340    Threshold,
341}
342
343impl CompactTrigger {
344    /// Parse from string with legacy alias support.
345    pub fn from_str_lenient(s: &str) -> Self {
346        match s.to_lowercase().as_str() {
347            "auto" | "automatic" => Self::Auto,
348            "threshold" => Self::Threshold,
349            _ => Self::Manual,
350        }
351    }
352}
353
354#[derive(Clone, Debug, Serialize, Deserialize)]
355pub struct CompactRecord {
356    pub id: Uuid,
357    pub session_id: SessionId,
358    pub trigger: CompactTrigger,
359    pub pre_tokens: usize,
360    pub post_tokens: usize,
361    pub saved_tokens: usize,
362    pub summary: String,
363    pub original_count: usize,
364    pub new_count: usize,
365    pub logical_parent_id: Option<MessageId>,
366    pub created_at: DateTime<Utc>,
367}
368
369impl CompactRecord {
370    pub fn new(session_id: SessionId) -> Self {
371        Self {
372            id: Uuid::new_v4(),
373            session_id,
374            trigger: CompactTrigger::default(),
375            pre_tokens: 0,
376            post_tokens: 0,
377            saved_tokens: 0,
378            summary: String::new(),
379            original_count: 0,
380            new_count: 0,
381            logical_parent_id: None,
382            created_at: Utc::now(),
383        }
384    }
385
386    pub fn with_trigger(mut self, trigger: CompactTrigger) -> Self {
387        self.trigger = trigger;
388        self
389    }
390
391    pub fn with_counts(mut self, original: usize, new: usize) -> Self {
392        self.original_count = original;
393        self.new_count = new;
394        self
395    }
396
397    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
398        self.summary = summary.into();
399        self
400    }
401
402    pub fn with_saved_tokens(mut self, saved: usize) -> Self {
403        self.saved_tokens = saved;
404        self
405    }
406
407    pub fn with_tokens(mut self, pre: usize, post: usize) -> Self {
408        self.pre_tokens = pre;
409        self.post_tokens = post;
410        self.saved_tokens = pre.saturating_sub(post);
411        self
412    }
413
414    pub fn with_logical_parent(mut self, parent_id: MessageId) -> Self {
415        self.logical_parent_id = Some(parent_id);
416        self
417    }
418}
419
420#[derive(Clone, Debug, Serialize, Deserialize)]
421pub struct SummarySnapshot {
422    pub id: Uuid,
423    pub session_id: SessionId,
424    pub summary: String,
425    pub leaf_message_id: Option<MessageId>,
426    pub created_at: DateTime<Utc>,
427}
428
429impl SummarySnapshot {
430    pub fn new(session_id: SessionId, summary: impl Into<String>) -> Self {
431        Self {
432            id: Uuid::new_v4(),
433            session_id,
434            summary: summary.into(),
435            leaf_message_id: None,
436            created_at: Utc::now(),
437        }
438    }
439
440    pub fn with_leaf(mut self, leaf_id: MessageId) -> Self {
441        self.leaf_message_id = Some(leaf_id);
442        self
443    }
444}
445
446#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum QueueOperation {
449    Enqueue,
450}
451
452#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(rename_all = "snake_case")]
454pub enum QueueStatus {
455    #[default]
456    Pending,
457    Processing,
458    Completed,
459    Cancelled,
460}
461
462#[derive(Clone, Debug, Serialize, Deserialize)]
463pub struct QueueItem {
464    pub id: Uuid,
465    pub session_id: SessionId,
466    pub operation: QueueOperation,
467    pub content: String,
468    pub priority: i32,
469    pub status: QueueStatus,
470    pub created_at: DateTime<Utc>,
471    pub processed_at: Option<DateTime<Utc>>,
472}
473
474impl QueueItem {
475    pub fn enqueue(session_id: SessionId, content: impl Into<String>) -> Self {
476        Self {
477            id: Uuid::new_v4(),
478            session_id,
479            operation: QueueOperation::Enqueue,
480            content: content.into(),
481            priority: 0,
482            status: QueueStatus::Pending,
483            created_at: Utc::now(),
484            processed_at: None,
485        }
486    }
487
488    pub fn with_priority(mut self, priority: i32) -> Self {
489        self.priority = priority;
490        self
491    }
492
493    pub fn start_processing(&mut self) {
494        self.status = QueueStatus::Processing;
495    }
496
497    pub fn complete(&mut self) {
498        self.status = QueueStatus::Completed;
499        self.processed_at = Some(Utc::now());
500    }
501
502    pub fn cancel(&mut self) {
503        self.status = QueueStatus::Cancelled;
504        self.processed_at = Some(Utc::now());
505    }
506}
507
508#[derive(Clone, Debug, Default, Serialize, Deserialize)]
509pub struct SessionStats {
510    pub total_messages: usize,
511    pub total_tool_calls: usize,
512    pub tool_success_count: usize,
513    pub tool_error_count: usize,
514    pub total_input_tokens: u64,
515    pub total_output_tokens: u64,
516    pub total_cost_usd: f64,
517    pub avg_tool_duration_ms: f64,
518    pub plans_count: usize,
519    pub todos_completed: usize,
520    pub todos_total: usize,
521    pub compacts_count: usize,
522    pub subagent_count: usize,
523}
524
525impl SessionStats {
526    pub fn tool_success_rate(&self) -> f64 {
527        if self.total_tool_calls == 0 {
528            1.0
529        } else {
530            self.tool_success_count as f64 / self.total_tool_calls as f64
531        }
532    }
533
534    pub fn total_tokens(&self) -> u64 {
535        self.total_input_tokens + self.total_output_tokens
536    }
537}
538
539#[derive(Clone, Debug, Serialize, Deserialize)]
540pub struct SessionTree {
541    pub session_id: SessionId,
542    pub session_type: super::state::SessionType,
543    pub stats: SessionStats,
544    pub children: Vec<SessionTree>,
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn test_environment_context() {
553        let ctx = EnvironmentContext::capture(None);
554        assert!(ctx.cwd.is_none());
555        assert!(ctx.platform.is_some());
556        assert!(ctx.sdk_version.is_some());
557    }
558
559    #[test]
560    fn test_tool_execution_builder() {
561        let session_id = SessionId::new();
562        let exec = ToolExecution::new(session_id, "Bash", serde_json::json!({"command": "ls"}))
563            .with_output("file1\nfile2", false)
564            .with_duration(150);
565
566        assert_eq!(exec.tool_name, "Bash");
567        assert_eq!(exec.duration_ms, 150);
568        assert!(!exec.is_error);
569    }
570
571    #[test]
572    fn test_plan_lifecycle() {
573        let session_id = SessionId::new();
574        let mut plan = Plan::new(session_id)
575            .with_name("Implement auth")
576            .with_content("1. Create user model\n2. Add endpoints");
577
578        assert_eq!(plan.status, PlanStatus::Draft);
579
580        plan.approve();
581        assert_eq!(plan.status, PlanStatus::Approved);
582        assert!(plan.approved_at.is_some());
583
584        plan.start_execution();
585        assert_eq!(plan.status, PlanStatus::Executing);
586
587        plan.complete();
588        assert_eq!(plan.status, PlanStatus::Completed);
589        assert!(plan.status.is_terminal());
590    }
591
592    #[test]
593    fn test_todo_item() {
594        let session_id = SessionId::new();
595        let mut todo = TodoItem::new(session_id, "Fix bug", "Fixing bug");
596
597        assert_eq!(todo.status, TodoStatus::Pending);
598        assert_eq!(todo.status_icon(), "○");
599
600        todo.start();
601        assert_eq!(todo.status, TodoStatus::InProgress);
602        assert_eq!(todo.status_icon(), "◐");
603
604        todo.complete();
605        assert_eq!(todo.status, TodoStatus::Completed);
606        assert_eq!(todo.status_icon(), "●");
607    }
608
609    #[test]
610    fn test_compact_record() {
611        let session_id = SessionId::new();
612        let record = CompactRecord::new(session_id)
613            .with_trigger(CompactTrigger::Threshold)
614            .with_tokens(100_000, 20_000)
615            .with_counts(50, 5)
616            .with_summary("Summary of conversation");
617
618        assert_eq!(record.pre_tokens, 100_000);
619        assert_eq!(record.post_tokens, 20_000);
620        assert_eq!(record.saved_tokens, 80_000);
621        assert_eq!(record.original_count, 50);
622        assert_eq!(record.new_count, 5);
623    }
624
625    #[test]
626    fn test_summary_snapshot() {
627        let session_id = SessionId::new();
628        let snapshot = SummarySnapshot::new(session_id, "Working on feature X");
629
630        assert!(!snapshot.summary.is_empty());
631        assert!(snapshot.leaf_message_id.is_none());
632    }
633
634    #[test]
635    fn test_queue_item() {
636        let session_id = SessionId::new();
637        let mut item = QueueItem::enqueue(session_id, "Process this").with_priority(10);
638
639        assert_eq!(item.status, QueueStatus::Pending);
640        assert_eq!(item.priority, 10);
641
642        item.start_processing();
643        assert_eq!(item.status, QueueStatus::Processing);
644
645        item.complete();
646        assert_eq!(item.status, QueueStatus::Completed);
647        assert!(item.processed_at.is_some());
648    }
649
650    #[test]
651    fn test_session_stats() {
652        let stats = SessionStats {
653            total_tool_calls: 10,
654            tool_success_count: 8,
655            tool_error_count: 2,
656            total_input_tokens: 1000,
657            total_output_tokens: 500,
658            ..Default::default()
659        };
660
661        assert!((stats.tool_success_rate() - 0.8).abs() < 0.001);
662        assert_eq!(stats.total_tokens(), 1500);
663    }
664
665    #[test]
666    fn test_status_from_str_lenient() {
667        // TodoStatus
668        assert_eq!(
669            TodoStatus::from_str_lenient("in_progress"),
670            TodoStatus::InProgress
671        );
672        assert_eq!(
673            TodoStatus::from_str_lenient("inprogress"),
674            TodoStatus::InProgress
675        );
676        assert_eq!(
677            TodoStatus::from_str_lenient("completed"),
678            TodoStatus::Completed
679        );
680        assert_eq!(TodoStatus::from_str_lenient("unknown"), TodoStatus::Pending);
681
682        // PlanStatus
683        assert_eq!(
684            PlanStatus::from_str_lenient("approved"),
685            PlanStatus::Approved
686        );
687        assert_eq!(
688            PlanStatus::from_str_lenient("executing"),
689            PlanStatus::Executing
690        );
691        assert_eq!(
692            PlanStatus::from_str_lenient("inprogress"),
693            PlanStatus::Executing
694        );
695        assert_eq!(
696            PlanStatus::from_str_lenient("cancelled"),
697            PlanStatus::Cancelled
698        );
699        assert_eq!(PlanStatus::from_str_lenient("unknown"), PlanStatus::Draft);
700
701        // CompactTrigger
702        assert_eq!(
703            CompactTrigger::from_str_lenient("auto"),
704            CompactTrigger::Auto
705        );
706        assert_eq!(
707            CompactTrigger::from_str_lenient("automatic"),
708            CompactTrigger::Auto
709        );
710        assert_eq!(
711            CompactTrigger::from_str_lenient("threshold"),
712            CompactTrigger::Threshold
713        );
714        assert_eq!(
715            CompactTrigger::from_str_lenient("unknown"),
716            CompactTrigger::Manual
717        );
718    }
719}