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