1use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::state::{MessageId, SessionId};
10
11#[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#[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 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 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 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 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 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 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}