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
179#[derive(Clone, Debug, Serialize, Deserialize)]
180pub struct Plan {
181    pub id: Uuid,
182    pub session_id: SessionId,
183    pub name: Option<String>,
184    pub content: String,
185    pub status: PlanStatus,
186    pub error: Option<String>,
187    pub created_at: DateTime<Utc>,
188    pub approved_at: Option<DateTime<Utc>>,
189    pub started_at: Option<DateTime<Utc>>,
190    pub completed_at: Option<DateTime<Utc>>,
191}
192
193impl Plan {
194    pub fn new(session_id: SessionId) -> Self {
195        Self {
196            id: Uuid::new_v4(),
197            session_id,
198            name: None,
199            content: String::new(),
200            status: PlanStatus::Draft,
201            error: None,
202            created_at: Utc::now(),
203            approved_at: None,
204            started_at: None,
205            completed_at: None,
206        }
207    }
208
209    pub fn with_name(mut self, name: impl Into<String>) -> Self {
210        self.name = Some(name.into());
211        self
212    }
213
214    pub fn with_content(mut self, content: impl Into<String>) -> Self {
215        self.content = content.into();
216        self
217    }
218
219    pub fn approve(&mut self) {
220        self.status = PlanStatus::Approved;
221        self.approved_at = Some(Utc::now());
222    }
223
224    pub fn start_execution(&mut self) {
225        self.status = PlanStatus::Executing;
226        self.started_at = Some(Utc::now());
227    }
228
229    pub fn complete(&mut self) {
230        self.status = PlanStatus::Completed;
231        self.completed_at = Some(Utc::now());
232    }
233
234    pub fn fail(&mut self, error: impl Into<String>) {
235        self.status = PlanStatus::Failed;
236        self.completed_at = Some(Utc::now());
237        self.error = Some(error.into());
238    }
239
240    pub fn cancel(&mut self) {
241        self.status = PlanStatus::Cancelled;
242        self.completed_at = Some(Utc::now());
243    }
244}
245
246#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum TodoStatus {
249    #[default]
250    Pending,
251    InProgress,
252    Completed,
253}
254
255#[derive(Clone, Debug, Serialize, Deserialize)]
256pub struct TodoItem {
257    pub id: Uuid,
258    pub session_id: SessionId,
259    pub content: String,
260    pub active_form: String,
261    pub status: TodoStatus,
262    pub plan_id: Option<Uuid>,
263    pub created_at: DateTime<Utc>,
264    pub started_at: Option<DateTime<Utc>>,
265    pub completed_at: Option<DateTime<Utc>>,
266}
267
268impl TodoItem {
269    pub fn new(
270        session_id: SessionId,
271        content: impl Into<String>,
272        active_form: impl Into<String>,
273    ) -> Self {
274        Self {
275            id: Uuid::new_v4(),
276            session_id,
277            content: content.into(),
278            active_form: active_form.into(),
279            status: TodoStatus::Pending,
280            plan_id: None,
281            created_at: Utc::now(),
282            started_at: None,
283            completed_at: None,
284        }
285    }
286
287    pub fn with_plan(mut self, plan_id: Uuid) -> Self {
288        self.plan_id = Some(plan_id);
289        self
290    }
291
292    pub fn start(&mut self) {
293        self.status = TodoStatus::InProgress;
294        self.started_at = Some(Utc::now());
295    }
296
297    pub fn complete(&mut self) {
298        self.status = TodoStatus::Completed;
299        self.completed_at = Some(Utc::now());
300    }
301
302    pub fn status_icon(&self) -> &'static str {
303        match self.status {
304            TodoStatus::Pending => "○",
305            TodoStatus::InProgress => "◐",
306            TodoStatus::Completed => "●",
307        }
308    }
309}
310
311#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313pub enum CompactTrigger {
314    #[default]
315    Manual,
316    Auto,
317    Threshold,
318}
319
320#[derive(Clone, Debug, Serialize, Deserialize)]
321pub struct CompactRecord {
322    pub id: Uuid,
323    pub session_id: SessionId,
324    pub trigger: CompactTrigger,
325    pub pre_tokens: usize,
326    pub post_tokens: usize,
327    pub saved_tokens: usize,
328    pub summary: String,
329    pub original_count: usize,
330    pub new_count: usize,
331    pub logical_parent_id: Option<MessageId>,
332    pub created_at: DateTime<Utc>,
333}
334
335impl CompactRecord {
336    pub fn new(session_id: SessionId) -> Self {
337        Self {
338            id: Uuid::new_v4(),
339            session_id,
340            trigger: CompactTrigger::default(),
341            pre_tokens: 0,
342            post_tokens: 0,
343            saved_tokens: 0,
344            summary: String::new(),
345            original_count: 0,
346            new_count: 0,
347            logical_parent_id: None,
348            created_at: Utc::now(),
349        }
350    }
351
352    pub fn with_trigger(mut self, trigger: CompactTrigger) -> Self {
353        self.trigger = trigger;
354        self
355    }
356
357    pub fn with_counts(mut self, original: usize, new: usize) -> Self {
358        self.original_count = original;
359        self.new_count = new;
360        self
361    }
362
363    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
364        self.summary = summary.into();
365        self
366    }
367
368    pub fn with_saved_tokens(mut self, saved: usize) -> Self {
369        self.saved_tokens = saved;
370        self
371    }
372
373    pub fn with_tokens(mut self, pre: usize, post: usize) -> Self {
374        self.pre_tokens = pre;
375        self.post_tokens = post;
376        self.saved_tokens = pre.saturating_sub(post);
377        self
378    }
379
380    pub fn with_logical_parent(mut self, parent_id: MessageId) -> Self {
381        self.logical_parent_id = Some(parent_id);
382        self
383    }
384}
385
386#[derive(Clone, Debug, Serialize, Deserialize)]
387pub struct SummarySnapshot {
388    pub id: Uuid,
389    pub session_id: SessionId,
390    pub summary: String,
391    pub leaf_message_id: Option<MessageId>,
392    pub created_at: DateTime<Utc>,
393}
394
395impl SummarySnapshot {
396    pub fn new(session_id: SessionId, summary: impl Into<String>) -> Self {
397        Self {
398            id: Uuid::new_v4(),
399            session_id,
400            summary: summary.into(),
401            leaf_message_id: None,
402            created_at: Utc::now(),
403        }
404    }
405
406    pub fn with_leaf(mut self, leaf_id: MessageId) -> Self {
407        self.leaf_message_id = Some(leaf_id);
408        self
409    }
410}
411
412#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case")]
414pub enum QueueOperation {
415    Enqueue,
416    Dequeue,
417    Cancel,
418}
419
420#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum QueueStatus {
423    #[default]
424    Pending,
425    Processing,
426    Completed,
427    Cancelled,
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize)]
431pub struct QueueItem {
432    pub id: Uuid,
433    pub session_id: SessionId,
434    pub operation: QueueOperation,
435    pub content: String,
436    pub priority: i32,
437    pub status: QueueStatus,
438    pub created_at: DateTime<Utc>,
439    pub processed_at: Option<DateTime<Utc>>,
440}
441
442impl QueueItem {
443    pub fn enqueue(session_id: SessionId, content: impl Into<String>) -> Self {
444        Self {
445            id: Uuid::new_v4(),
446            session_id,
447            operation: QueueOperation::Enqueue,
448            content: content.into(),
449            priority: 0,
450            status: QueueStatus::Pending,
451            created_at: Utc::now(),
452            processed_at: None,
453        }
454    }
455
456    pub fn with_priority(mut self, priority: i32) -> Self {
457        self.priority = priority;
458        self
459    }
460
461    pub fn start_processing(&mut self) {
462        self.status = QueueStatus::Processing;
463    }
464
465    pub fn complete(&mut self) {
466        self.status = QueueStatus::Completed;
467        self.processed_at = Some(Utc::now());
468    }
469
470    pub fn cancel(&mut self) {
471        self.status = QueueStatus::Cancelled;
472        self.processed_at = Some(Utc::now());
473    }
474}
475
476#[derive(Clone, Debug, Default)]
477pub struct ToolExecutionFilter {
478    pub tool_name: Option<String>,
479    pub plan_id: Option<Uuid>,
480    pub is_error: Option<bool>,
481    pub limit: Option<usize>,
482    pub offset: Option<usize>,
483}
484
485impl ToolExecutionFilter {
486    pub fn by_tool(tool_name: impl Into<String>) -> Self {
487        Self {
488            tool_name: Some(tool_name.into()),
489            ..Default::default()
490        }
491    }
492
493    pub fn by_plan(plan_id: Uuid) -> Self {
494        Self {
495            plan_id: Some(plan_id),
496            ..Default::default()
497        }
498    }
499
500    pub fn errors_only() -> Self {
501        Self {
502            is_error: Some(true),
503            ..Default::default()
504        }
505    }
506
507    pub fn with_limit(mut self, limit: usize) -> Self {
508        self.limit = Some(limit);
509        self
510    }
511}
512
513#[derive(Clone, Debug, Default, Serialize, Deserialize)]
514pub struct SessionStats {
515    pub total_messages: usize,
516    pub total_tool_calls: usize,
517    pub tool_success_count: usize,
518    pub tool_error_count: usize,
519    pub total_input_tokens: u64,
520    pub total_output_tokens: u64,
521    pub total_cost_usd: f64,
522    pub avg_tool_duration_ms: f64,
523    pub plans_count: usize,
524    pub todos_completed: usize,
525    pub todos_total: usize,
526    pub compacts_count: usize,
527    pub subagent_count: usize,
528}
529
530impl SessionStats {
531    pub fn tool_success_rate(&self) -> f64 {
532        if self.total_tool_calls == 0 {
533            1.0
534        } else {
535            self.tool_success_count as f64 / self.total_tool_calls as f64
536        }
537    }
538
539    pub fn total_tokens(&self) -> u64 {
540        self.total_input_tokens + self.total_output_tokens
541    }
542}
543
544#[derive(Clone, Debug, Serialize, Deserialize)]
545pub struct SessionTree {
546    pub session_id: SessionId,
547    pub session_type: super::state::SessionType,
548    pub stats: SessionStats,
549    pub children: Vec<SessionTree>,
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_environment_context() {
558        let ctx = EnvironmentContext::capture(None);
559        assert!(ctx.cwd.is_none());
560        assert!(ctx.platform.is_some());
561        assert!(ctx.sdk_version.is_some());
562    }
563
564    #[test]
565    fn test_tool_execution_builder() {
566        let session_id = SessionId::new();
567        let exec = ToolExecution::new(session_id, "Bash", serde_json::json!({"command": "ls"}))
568            .with_output("file1\nfile2", false)
569            .with_duration(150);
570
571        assert_eq!(exec.tool_name, "Bash");
572        assert_eq!(exec.duration_ms, 150);
573        assert!(!exec.is_error);
574    }
575
576    #[test]
577    fn test_plan_lifecycle() {
578        let session_id = SessionId::new();
579        let mut plan = Plan::new(session_id)
580            .with_name("Implement auth")
581            .with_content("1. Create user model\n2. Add endpoints");
582
583        assert_eq!(plan.status, PlanStatus::Draft);
584
585        plan.approve();
586        assert_eq!(plan.status, PlanStatus::Approved);
587        assert!(plan.approved_at.is_some());
588
589        plan.start_execution();
590        assert_eq!(plan.status, PlanStatus::Executing);
591
592        plan.complete();
593        assert_eq!(plan.status, PlanStatus::Completed);
594        assert!(plan.status.is_terminal());
595    }
596
597    #[test]
598    fn test_todo_item() {
599        let session_id = SessionId::new();
600        let mut todo = TodoItem::new(session_id, "Fix bug", "Fixing bug");
601
602        assert_eq!(todo.status, TodoStatus::Pending);
603        assert_eq!(todo.status_icon(), "○");
604
605        todo.start();
606        assert_eq!(todo.status, TodoStatus::InProgress);
607        assert_eq!(todo.status_icon(), "◐");
608
609        todo.complete();
610        assert_eq!(todo.status, TodoStatus::Completed);
611        assert_eq!(todo.status_icon(), "●");
612    }
613
614    #[test]
615    fn test_compact_record() {
616        let session_id = SessionId::new();
617        let record = CompactRecord::new(session_id)
618            .with_trigger(CompactTrigger::Threshold)
619            .with_tokens(100_000, 20_000)
620            .with_counts(50, 5)
621            .with_summary("Summary of conversation");
622
623        assert_eq!(record.pre_tokens, 100_000);
624        assert_eq!(record.post_tokens, 20_000);
625        assert_eq!(record.saved_tokens, 80_000);
626        assert_eq!(record.original_count, 50);
627        assert_eq!(record.new_count, 5);
628    }
629
630    #[test]
631    fn test_summary_snapshot() {
632        let session_id = SessionId::new();
633        let snapshot = SummarySnapshot::new(session_id, "Working on feature X");
634
635        assert!(!snapshot.summary.is_empty());
636        assert!(snapshot.leaf_message_id.is_none());
637    }
638
639    #[test]
640    fn test_queue_item() {
641        let session_id = SessionId::new();
642        let mut item = QueueItem::enqueue(session_id, "Process this").with_priority(10);
643
644        assert_eq!(item.status, QueueStatus::Pending);
645        assert_eq!(item.priority, 10);
646
647        item.start_processing();
648        assert_eq!(item.status, QueueStatus::Processing);
649
650        item.complete();
651        assert_eq!(item.status, QueueStatus::Completed);
652        assert!(item.processed_at.is_some());
653    }
654
655    #[test]
656    fn test_session_stats() {
657        let stats = SessionStats {
658            total_tool_calls: 10,
659            tool_success_count: 8,
660            tool_error_count: 2,
661            total_input_tokens: 1000,
662            total_output_tokens: 500,
663            ..Default::default()
664        };
665
666        assert!((stats.tool_success_rate() - 0.8).abs() < 0.001);
667        assert_eq!(stats.total_tokens(), 1500);
668    }
669}