use crate::lifecycle::{LifecycleEvent, NeedsInputReason, NotificationKind};
use crate::{AgentType, ContextUsage, Model, Money, TokenCount};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::VecDeque;
use std::fmt;
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(String);
pub const PENDING_SESSION_PREFIX: &str = "pending-";
impl SessionId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn pending_from_pid(pid: u32) -> Self {
Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
}
#[must_use]
pub fn is_pending(&self) -> bool {
self.0.starts_with(PENDING_SESSION_PREFIX)
}
pub fn pending_pid(&self) -> Option<u32> {
if !self.is_pending() {
return None;
}
self.0
.strip_prefix(PENDING_SESSION_PREFIX)
.and_then(|s| s.parse().ok())
}
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn short(&self) -> &str {
self.0.get(..8).unwrap_or(&self.0)
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for SessionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ToolUseId(String);
impl ToolUseId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ToolUseId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ToolUseId {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TranscriptPath(PathBuf);
impl TranscriptPath {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn filename(&self) -> Option<&str> {
self.0.file_name().and_then(|n| n.to_str())
}
}
impl fmt::Display for TranscriptPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.display())
}
}
impl AsRef<Path> for TranscriptPath {
fn as_ref(&self) -> &Path {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
#[default]
Idle,
Working,
AttentionNeeded,
}
impl SessionStatus {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::AttentionNeeded => "needs input",
}
}
#[must_use]
pub fn icon(&self) -> &'static str {
match self {
Self::Idle => "-",
Self::Working => ">",
Self::AttentionNeeded => "!",
}
}
#[must_use]
pub fn should_blink(&self) -> bool {
matches!(self, Self::AttentionNeeded)
}
#[must_use]
pub fn is_active(&self) -> bool {
matches!(self, Self::Working)
}
#[must_use]
pub fn needs_attention(&self) -> bool {
matches!(self, Self::AttentionNeeded)
}
}
impl fmt::Display for SessionStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Idle => write!(f, "Idle"),
Self::Working => write!(f, "Working"),
Self::AttentionNeeded => write!(f, "Needs Input"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityDetail {
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
impl ActivityDetail {
pub fn new(tool_name: &str) -> Self {
Self {
tool_name: Some(tool_name.to_string()),
started_at: Utc::now(),
context: None,
}
}
pub fn with_context(context: &str) -> Self {
Self {
tool_name: None,
started_at: Utc::now(),
context: Some(context.to_string()),
}
}
pub fn thinking() -> Self {
Self::with_context("Thinking")
}
pub fn duration(&self) -> chrono::Duration {
Utc::now().signed_duration_since(self.started_at)
}
#[must_use]
pub fn display(&self) -> Cow<'_, str> {
if let Some(ref tool) = self.tool_name {
Cow::Borrowed(tool)
} else if let Some(ref ctx) = self.context {
Cow::Borrowed(ctx)
} else {
Cow::Borrowed("Unknown")
}
}
}
impl Default for ActivityDetail {
fn default() -> Self {
Self::thinking()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SessionDuration {
total_ms: u64,
api_ms: u64,
}
impl SessionDuration {
pub fn new(total_ms: u64, api_ms: u64) -> Self {
Self { total_ms, api_ms }
}
pub fn from_total_ms(total_ms: u64) -> Self {
Self {
total_ms,
api_ms: 0,
}
}
pub fn total_ms(&self) -> u64 {
self.total_ms
}
pub fn api_ms(&self) -> u64 {
self.api_ms
}
pub fn total_seconds(&self) -> f64 {
self.total_ms as f64 / 1000.0
}
pub fn overhead_ms(&self) -> u64 {
self.total_ms.saturating_sub(self.api_ms)
}
pub fn format(&self) -> String {
let secs = self.total_ms / 1000;
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
let mins = secs / 60;
let remaining_secs = secs % 60;
if remaining_secs == 0 {
format!("{mins}m")
} else {
format!("{mins}m {remaining_secs}s")
}
} else {
let hours = secs / 3600;
let remaining_mins = (secs % 3600) / 60;
if remaining_mins == 0 {
format!("{hours}h")
} else {
format!("{hours}h {remaining_mins}m")
}
}
}
pub fn format_compact(&self) -> String {
let secs = self.total_ms / 1000;
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
let mins = secs / 60;
format!("{mins}m")
} else {
let hours = secs / 3600;
format!("{hours}h")
}
}
}
impl fmt::Display for SessionDuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.format())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct LinesChanged {
pub added: u64,
pub removed: u64,
}
impl LinesChanged {
pub fn new(added: u64, removed: u64) -> Self {
Self { added, removed }
}
pub fn net(&self) -> i64 {
self.added as i64 - self.removed as i64
}
pub fn churn(&self) -> u64 {
self.added.saturating_add(self.removed)
}
pub fn is_empty(&self) -> bool {
self.added == 0 && self.removed == 0
}
pub fn format(&self) -> String {
format!("+{} -{}", self.added, self.removed)
}
pub fn format_net(&self) -> String {
let net = self.net();
if net >= 0 {
format!("+{net}")
} else {
format!("{net}")
}
}
}
impl fmt::Display for LinesChanged {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.format())
}
}
#[derive(Debug, Clone, Default)]
pub struct StatusLineData {
pub session_id: String,
pub model_id: String,
pub model_display_name: Option<String>,
pub cost_usd: f64,
pub total_duration_ms: u64,
pub api_duration_ms: u64,
pub lines_added: u64,
pub lines_removed: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub context_window_size: u32,
pub current_input_tokens: u64,
pub current_output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub cwd: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionDomain {
pub id: SessionId,
pub agent_type: AgentType,
#[serde(default)]
pub harness: crate::Harness,
pub model: Model,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_display_override: Option<String>,
pub status: SessionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_activity: Option<ActivityDetail>,
pub context: ContextUsage,
pub cost: Money,
pub duration: SessionDuration,
pub lines_changed: LinesChanged,
pub started_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tmux_pane: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<SessionId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub child_session_ids: Vec<SessionId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_prompt: Option<String>,
}
impl SessionDomain {
pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
let now = Utc::now();
Self {
id,
agent_type,
harness: crate::Harness::default(),
model,
model_display_override: None,
status: SessionStatus::Idle,
current_activity: None,
context: ContextUsage::new(model.context_window_size()),
cost: Money::zero(),
duration: SessionDuration::default(),
lines_changed: LinesChanged::default(),
started_at: now,
last_activity: now,
working_directory: None,
claude_code_version: None,
tmux_pane: None,
project_root: None,
worktree_path: None,
worktree_branch: None,
parent_session_id: None,
child_session_ids: Vec::new(),
first_prompt: None,
}
}
pub fn from_status_line(data: &StatusLineData) -> Self {
use crate::model::derive_display_name;
let model = Model::from_id(&data.model_id);
let mut session = Self::new(
SessionId::new(&data.session_id),
AgentType::GeneralPurpose, model,
);
session.harness = crate::Harness::ClaudeCode;
if model.is_unknown() && !data.model_id.is_empty() {
session.model_display_override = Some(
data.model_display_name
.clone()
.unwrap_or_else(|| derive_display_name(&data.model_id)),
);
}
session.cost = Money::from_usd(data.cost_usd);
session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
session.context = ContextUsage {
total_input_tokens: TokenCount::new(data.total_input_tokens),
total_output_tokens: TokenCount::new(data.total_output_tokens),
context_window_size: data.context_window_size,
current_input_tokens: TokenCount::new(data.current_input_tokens),
current_output_tokens: TokenCount::new(data.current_output_tokens),
cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
cache_read_tokens: TokenCount::new(data.cache_read_tokens),
};
session.working_directory = data.cwd.clone();
session.claude_code_version = data.version.clone();
session.last_activity = Utc::now();
session
}
pub fn update_from_status_line(&mut self, data: &StatusLineData) -> bool {
self.cost = Money::from_usd(data.cost_usd);
self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
self.last_activity = Utc::now();
if self.status != SessionStatus::AttentionNeeded {
self.status = SessionStatus::Working;
}
let cwd_changed = match (&data.cwd, &self.working_directory) {
(Some(new_cwd), Some(old_cwd)) => new_cwd != old_cwd,
(Some(_), None) => true,
_ => false,
};
if cwd_changed {
self.working_directory = data.cwd.clone();
}
cwd_changed
}
pub fn apply_lifecycle_event(&mut self, event: &LifecycleEvent) {
self.last_activity = Utc::now();
match event {
LifecycleEvent::SessionStart { .. } => {
self.status = SessionStatus::Idle;
self.current_activity = None;
}
LifecycleEvent::SessionEnd { .. } => {
self.status = SessionStatus::Idle;
self.current_activity = None;
}
LifecycleEvent::WorkingStart => {
self.status = SessionStatus::Working;
self.current_activity = None;
}
LifecycleEvent::WorkingEnd | LifecycleEvent::Idle => {
self.status = SessionStatus::Idle;
self.current_activity = None;
}
LifecycleEvent::PromptSubmit { .. } => {
self.status = SessionStatus::Working;
self.current_activity = None;
}
LifecycleEvent::NeedsInput { reason } => {
self.status = SessionStatus::AttentionNeeded;
self.current_activity = Some(activity_for_needs_input(reason));
}
LifecycleEvent::ToolCallStart { name, .. } => {
self.status = SessionStatus::Working;
self.current_activity = Some(ActivityDetail::new(name.as_str()));
}
LifecycleEvent::ToolCallEnd { .. } => {
self.status = SessionStatus::Working;
self.current_activity = Some(ActivityDetail::thinking());
}
LifecycleEvent::ContextCompactStart { .. } => {
self.status = SessionStatus::Working;
self.current_activity = Some(ActivityDetail::with_context("Compacting"));
}
LifecycleEvent::ContextUpdate { tokens, cost_usd } => {
if let Some(c) = cost_usd {
self.cost = Money::from_usd(*c);
}
if let Some(t) = tokens {
let count = TokenCount::new(*t);
self.context.current_input_tokens = count;
self.context.total_input_tokens = count;
}
}
LifecycleEvent::ProviderModelChange { model, .. } => {
if let Some(id) = model {
let parsed = Model::from_id(id);
self.model = parsed;
self.model_display_override = if parsed.is_unknown() {
Some(crate::model::derive_display_name(id))
} else {
None
};
}
}
LifecycleEvent::Notification { kind, .. } => {
if matches!(kind, Some(NotificationKind::Setup)) {
self.status = SessionStatus::Working;
self.current_activity = Some(ActivityDetail::with_context("Setup"));
}
}
LifecycleEvent::ChildSessionStart { .. } | LifecycleEvent::ChildSessionEnd { .. } => {
self.status = SessionStatus::Working;
}
}
}
pub fn set_first_prompt_from_event(&mut self, event: &LifecycleEvent) {
if let LifecycleEvent::PromptSubmit { prompt: Some(text) } = event {
if !text.is_empty() {
self.set_first_prompt(text);
}
}
}
pub fn set_first_prompt(&mut self, prompt: &str) {
if self.first_prompt.is_none() && !prompt.is_empty() {
self.first_prompt = Some(prompt.to_string());
}
}
pub fn age(&self) -> chrono::Duration {
Utc::now().signed_duration_since(self.started_at)
}
pub fn time_since_activity(&self) -> chrono::Duration {
Utc::now().signed_duration_since(self.last_activity)
}
pub fn needs_context_attention(&self) -> bool {
self.context.is_warning() || self.context.is_critical()
}
}
impl Default for SessionDomain {
fn default() -> Self {
Self::new(
SessionId::new("unknown"),
AgentType::default(),
Model::default(),
)
}
}
#[derive(Debug, Clone)]
pub struct ToolUsageRecord {
pub tool_name: String,
pub tool_use_id: Option<ToolUseId>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct SessionInfrastructure {
pub pid: Option<u32>,
pub process_start_time: Option<u64>,
pub socket_path: Option<PathBuf>,
pub transcript_path: Option<TranscriptPath>,
pub recent_tools: VecDeque<ToolUsageRecord>,
pub update_count: u64,
pub hook_event_count: u64,
pub last_error: Option<String>,
}
impl SessionInfrastructure {
const MAX_TOOL_HISTORY: usize = 50;
pub fn new() -> Self {
Self {
pid: None,
process_start_time: None,
socket_path: None,
transcript_path: None,
recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
update_count: 0,
hook_event_count: 0,
last_error: None,
}
}
pub fn set_pid(&mut self, pid: u32) {
if pid == 0 {
return;
}
if self.pid == Some(pid) {
return;
}
if let Some(start_time) = read_process_start_time(pid) {
self.pid = Some(pid);
self.process_start_time = Some(start_time);
} else {
debug!(
pid = pid,
"PID validation failed - process may have exited or is inaccessible"
);
}
}
pub fn is_process_alive(&self) -> bool {
let Some(pid) = self.pid else {
debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
return true;
};
let Some(expected_start_time) = self.process_start_time else {
let exists = procfs::process::Process::new(pid as i32).is_ok();
debug!(
pid,
exists, "is_process_alive: no start_time, checking procfs only"
);
return exists;
};
match read_process_start_time(pid) {
Some(current_start_time) => {
let alive = current_start_time == expected_start_time;
if !alive {
debug!(
pid,
expected_start_time,
current_start_time,
"is_process_alive: start time MISMATCH - PID reused?"
);
}
alive
}
None => {
debug!(
pid,
expected_start_time, "is_process_alive: process NOT FOUND in /proc"
);
false
}
}
}
pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
let record = ToolUsageRecord {
tool_name: tool_name.to_string(),
tool_use_id,
timestamp: Utc::now(),
};
self.recent_tools.push_back(record);
while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
self.recent_tools.pop_front();
}
self.hook_event_count += 1;
}
pub fn record_update(&mut self) {
self.update_count += 1;
}
pub fn record_error(&mut self, error: &str) {
self.last_error = Some(error.to_string());
}
pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
self.recent_tools.back()
}
pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
self.recent_tools.iter().rev()
}
}
fn activity_for_needs_input(reason: &NeedsInputReason) -> ActivityDetail {
match reason {
NeedsInputReason::InteractiveTool { tool } | NeedsInputReason::PermissionGate { tool } => {
ActivityDetail::new(tool.as_str())
}
NeedsInputReason::Notification { kind, label } => {
if let Some(text) = label.as_deref() {
return ActivityDetail::with_context(text);
}
match kind {
NotificationKind::PermissionPrompt => ActivityDetail::with_context("Permission"),
NotificationKind::ElicitationDialog => ActivityDetail::with_context("MCP Input"),
other => ActivityDetail::with_context(other.as_str()),
}
}
}
}
fn read_process_start_time(pid: u32) -> Option<u64> {
let process = procfs::process::Process::new(pid as i32).ok()?;
let stat = process.stat().ok()?;
Some(stat.starttime)
}
impl Default for SessionInfrastructure {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionView {
pub id: SessionId,
pub id_short: String,
pub agent_type: String,
#[serde(default)]
pub harness: String,
pub model: String,
pub status: SessionStatus,
pub status_label: String,
pub activity_detail: Option<String>,
pub should_blink: bool,
pub status_icon: String,
pub context_percentage: f64,
pub context_display: String,
pub context_warning: bool,
pub context_critical: bool,
pub cost_display: String,
pub cost_usd: f64,
pub duration_display: String,
pub duration_seconds: f64,
pub lines_display: String,
pub working_directory: Option<String>,
pub needs_attention: bool,
pub last_activity_display: String,
pub age_display: String,
pub started_at: String,
pub last_activity: String,
pub tmux_pane: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<SessionId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub child_session_ids: Vec<SessionId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_prompt: Option<String>,
}
impl SessionView {
pub fn from_domain(session: &SessionDomain) -> Self {
let now = Utc::now();
let since_activity = now.signed_duration_since(session.last_activity);
let age = now.signed_duration_since(session.started_at);
Self {
id: session.id.clone(),
id_short: session.id.short().to_string(),
agent_type: session.agent_type.short_name().to_string(),
harness: session.harness.short_tag().to_string(),
model: if session.model.is_unknown() {
session
.model_display_override
.clone()
.unwrap_or_else(|| session.model.display_name().to_string())
} else {
session.model.display_name().to_string()
},
status: session.status,
status_label: session.status.label().to_string(),
activity_detail: session
.current_activity
.as_ref()
.map(|a| a.display().into_owned()),
should_blink: session.status.should_blink(),
status_icon: session.status.icon().to_string(),
context_percentage: session.context.usage_percentage(),
context_display: session.context.format(),
context_warning: session.context.is_warning(),
context_critical: session.context.is_critical(),
cost_display: session.cost.format(),
cost_usd: session.cost.as_usd(),
duration_display: session.duration.format(),
duration_seconds: session.duration.total_seconds(),
lines_display: session.lines_changed.format(),
working_directory: session.working_directory.clone().map(|p| {
if p.len() > 30 {
format!("...{}", &p[p.len().saturating_sub(27)..])
} else {
p
}
}),
needs_attention: session.status.needs_attention() || session.needs_context_attention(),
last_activity_display: format_duration(since_activity),
age_display: format_duration(age),
started_at: session.started_at.to_rfc3339(),
last_activity: session.last_activity.to_rfc3339(),
tmux_pane: session.tmux_pane.clone(),
project_root: session.project_root.clone(),
worktree_path: session.worktree_path.clone(),
worktree_branch: session.worktree_branch.clone(),
parent_session_id: session.parent_session_id.clone(),
child_session_ids: session.child_session_ids.clone(),
first_prompt: session.first_prompt.clone(),
}
}
}
impl From<&SessionDomain> for SessionView {
fn from(session: &SessionDomain) -> Self {
Self::from_domain(session)
}
}
fn format_duration(duration: chrono::Duration) -> String {
let secs = duration.num_seconds();
if secs < 0 {
return "now".to_string();
}
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
let mins = secs / 60;
format!("{mins}m ago")
} else if secs < 86400 {
let hours = secs / 3600;
format!("{hours}h ago")
} else {
let days = secs / 86400;
format!("{days}d ago")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::Tool;
fn create_test_session(id: &str) -> SessionDomain {
SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
}
#[test]
fn test_session_id_short() {
let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
assert_eq!(id.short(), "8e11bfb5");
}
#[test]
fn test_session_id_short_short_id() {
let id = SessionId::new("abc");
assert_eq!(id.short(), "abc");
}
#[test]
fn test_session_status_display() {
let status = SessionStatus::Working;
assert_eq!(format!("{status}"), "Working");
}
#[test]
fn test_session_domain_creation() {
let session = SessionDomain::new(
SessionId::new("test-123"),
AgentType::GeneralPurpose,
Model::Opus45,
);
assert_eq!(session.id.as_str(), "test-123");
assert_eq!(session.model, Model::Opus45);
assert!(session.cost.is_zero());
}
#[test]
fn test_session_view_from_domain() {
let session = SessionDomain::new(
SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
AgentType::Explore,
Model::Sonnet4,
);
let view = SessionView::from_domain(&session);
assert_eq!(view.id_short, "8e11bfb5");
assert_eq!(view.agent_type, "explore");
assert_eq!(view.model, "Sonnet 4");
}
#[test]
fn test_session_view_unknown_model_with_override() {
let mut session = SessionDomain::new(
SessionId::new("test-override"),
AgentType::GeneralPurpose,
Model::Unknown,
);
session.model_display_override = Some("GPT-4o".to_string());
let view = SessionView::from_domain(&session);
assert_eq!(view.model, "GPT-4o");
}
#[test]
fn test_session_view_unknown_model_without_override() {
let session = SessionDomain::new(
SessionId::new("test-no-override"),
AgentType::GeneralPurpose,
Model::Unknown,
);
let view = SessionView::from_domain(&session);
assert_eq!(view.model, "Unknown");
}
#[test]
fn test_session_view_known_model_ignores_override() {
let mut session = SessionDomain::new(
SessionId::new("test-known"),
AgentType::GeneralPurpose,
Model::Opus46,
);
session.model_display_override = Some("something else".to_string());
let view = SessionView::from_domain(&session);
assert_eq!(view.model, "Opus 4.6");
}
#[test]
fn test_lines_changed() {
let lines = LinesChanged::new(150, 30);
assert_eq!(lines.net(), 120);
assert_eq!(lines.churn(), 180);
assert_eq!(lines.format(), "+150 -30");
assert_eq!(lines.format_net(), "+120");
}
#[test]
fn test_session_duration_formatting() {
assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
}
#[test]
fn test_session_id_pending_from_pid() {
let id = SessionId::pending_from_pid(12345);
assert_eq!(id.as_str(), "pending-12345");
assert!(id.is_pending());
assert_eq!(id.pending_pid(), Some(12345));
}
#[test]
fn test_session_id_is_pending_true() {
let id = SessionId::new("pending-99999");
assert!(id.is_pending());
}
#[test]
fn test_session_id_is_pending_false() {
let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
assert!(!id.is_pending());
}
#[test]
fn test_session_id_pending_pid_returns_none_for_regular_id() {
let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
assert_eq!(id.pending_pid(), None);
}
#[test]
fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
let id = SessionId::new("pending-not-a-number");
assert_eq!(id.pending_pid(), None);
}
#[test]
fn lifecycle_provider_model_change_known_claude_id() {
let mut session = create_test_session("test-pmc-known");
session.model = Model::Unknown;
session.model_display_override = Some("stale".to_string());
session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
provider: Some("anthropic".to_string()),
model: Some("claude-sonnet-4-5-20250929".to_string()),
});
assert_eq!(session.model, Model::Sonnet45);
assert!(
session.model_display_override.is_none(),
"override must be cleared when the id maps to a known model"
);
}
#[test]
fn lifecycle_provider_model_change_unknown_id() {
let mut session = create_test_session("test-pmc-unknown");
session.model = Model::Unknown;
session.model_display_override = None;
session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
});
assert_eq!(session.model, Model::Unknown);
assert_eq!(session.model_display_override.as_deref(), Some("gpt-4o"));
}
#[test]
fn lifecycle_provider_model_change_no_model_is_noop() {
let mut session = create_test_session("test-pmc-none");
session.model = Model::Sonnet4;
session.model_display_override = None;
session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
provider: Some("anthropic".to_string()),
model: None,
});
assert_eq!(session.model, Model::Sonnet4);
assert!(session.model_display_override.is_none());
}
#[test]
fn lifecycle_needs_input_for_interactive_tool() {
let mut session = create_test_session("test-interactive");
session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
reason: NeedsInputReason::InteractiveTool {
tool: Tool::AskUserQuestion,
},
});
assert_eq!(session.status, SessionStatus::AttentionNeeded);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("AskUserQuestion")
);
session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
name: Tool::AskUserQuestion,
tool_use_id: None,
is_error: false,
});
assert_eq!(session.status, SessionStatus::Working);
}
#[test]
fn lifecycle_needs_input_for_enter_plan_mode() {
let mut session = create_test_session("test-plan");
session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
reason: NeedsInputReason::InteractiveTool {
tool: Tool::EnterPlanMode,
},
});
assert_eq!(session.status, SessionStatus::AttentionNeeded);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("EnterPlanMode")
);
}
#[test]
fn lifecycle_needs_input_notification_uses_label_when_present() {
let mut session = create_test_session("test-label");
session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
reason: NeedsInputReason::Notification {
kind: NotificationKind::PermissionPrompt,
label: Some("Allow `rm -rf /tmp/cache`?".into()),
},
});
assert_eq!(session.status, SessionStatus::AttentionNeeded);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("Allow `rm -rf /tmp/cache`?")
);
}
#[test]
fn lifecycle_needs_input_notification_falls_back_to_kind_when_label_absent() {
let mut session = create_test_session("test-no-label");
session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
reason: NeedsInputReason::Notification {
kind: NotificationKind::PermissionPrompt,
label: None,
},
});
assert_eq!(session.status, SessionStatus::AttentionNeeded);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("Permission")
);
session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
reason: NeedsInputReason::Notification {
kind: NotificationKind::ElicitationDialog,
label: None,
},
});
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("MCP Input")
);
}
#[test]
fn lifecycle_tool_call_start_for_standard_tool() {
let mut session = create_test_session("test-standard");
session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
name: Tool::Bash,
tool_use_id: None,
input: None,
});
assert_eq!(session.status, SessionStatus::Working);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("Bash")
);
session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
name: Tool::Bash,
tool_use_id: None,
is_error: false,
});
assert_eq!(session.status, SessionStatus::Working);
}
#[test]
fn lifecycle_unknown_tool_lands_in_other_and_keeps_name() {
let mut session = create_test_session("test-other");
session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
name: Tool::Other("custom_pi_tool".into()),
tool_use_id: None,
input: None,
});
assert_eq!(session.status, SessionStatus::Working);
assert_eq!(
session
.current_activity
.as_ref()
.map(|a| a.display())
.as_deref(),
Some("custom_pi_tool")
);
}
#[test]
fn test_activity_detail_creation() {
let detail = ActivityDetail::new("Bash");
assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
assert!(detail.started_at <= Utc::now());
assert!(detail.context.is_none());
}
#[test]
fn test_activity_detail_with_context() {
let detail = ActivityDetail::with_context("Compacting");
assert!(detail.tool_name.is_none());
assert_eq!(detail.context.as_deref(), Some("Compacting"));
}
#[test]
fn test_activity_detail_display() {
let detail = ActivityDetail::new("Read");
assert_eq!(detail.display(), "Read");
let context_detail = ActivityDetail::with_context("Setup");
assert_eq!(context_detail.display(), "Setup");
}
#[test]
fn test_new_session_status_variants() {
let idle = SessionStatus::Idle;
let working = SessionStatus::Working;
let attention = SessionStatus::AttentionNeeded;
assert_eq!(idle.label(), "idle");
assert_eq!(working.label(), "working");
assert_eq!(attention.label(), "needs input");
}
#[test]
fn test_session_status_should_blink() {
assert!(!SessionStatus::Idle.should_blink());
assert!(!SessionStatus::Working.should_blink());
assert!(SessionStatus::AttentionNeeded.should_blink());
}
#[test]
fn test_session_status_icons() {
assert_eq!(SessionStatus::Idle.icon(), "-");
assert_eq!(SessionStatus::Working.icon(), ">");
assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
}
#[test]
fn test_session_domain_new_fields_default() {
let session = create_test_session("test-defaults");
assert!(session.project_root.is_none());
assert!(session.worktree_path.is_none());
assert!(session.worktree_branch.is_none());
assert!(session.parent_session_id.is_none());
assert!(session.child_session_ids.is_empty());
}
#[test]
fn test_session_view_includes_new_fields() {
let mut session = create_test_session("test-view-fields");
session.project_root = Some("/home/user/project".to_string());
session.worktree_path = Some("/home/user/worktree".to_string());
session.worktree_branch = Some("feature-x".to_string());
session.parent_session_id = Some(SessionId::new("parent-123"));
session.child_session_ids = vec![SessionId::new("child-1"), SessionId::new("child-2")];
let view = SessionView::from_domain(&session);
assert_eq!(view.project_root, Some("/home/user/project".to_string()));
assert_eq!(view.worktree_path, Some("/home/user/worktree".to_string()));
assert_eq!(view.worktree_branch, Some("feature-x".to_string()));
assert_eq!(view.parent_session_id, Some(SessionId::new("parent-123")));
assert_eq!(view.child_session_ids.len(), 2);
assert_eq!(view.child_session_ids[0].as_str(), "child-1");
assert_eq!(view.child_session_ids[1].as_str(), "child-2");
}
fn make_status_data(cwd: Option<&str>) -> StatusLineData {
StatusLineData {
session_id: "test".to_string(),
model_id: "claude-sonnet-4-20250514".to_string(),
model_display_name: None,
cost_usd: 0.10,
total_duration_ms: 1000,
api_duration_ms: 500,
lines_added: 10,
lines_removed: 5,
total_input_tokens: 1000,
total_output_tokens: 500,
context_window_size: 200_000,
current_input_tokens: 800,
current_output_tokens: 400,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cwd: cwd.map(|s| s.to_string()),
version: None,
}
}
#[test]
fn test_update_from_status_line_cwd_changed() {
let mut session = SessionDomain::new(
SessionId::new("test"),
AgentType::GeneralPurpose,
Model::Sonnet4,
);
session.working_directory = Some("/home/user/repo-a".to_string());
let data = make_status_data(Some("/home/user/repo-b"));
let changed = session.update_from_status_line(&data);
assert!(changed, "should return true when cwd changes");
assert_eq!(
session.working_directory.as_deref(),
Some("/home/user/repo-b"),
"working_directory should be updated"
);
}
#[test]
fn test_update_from_status_line_cwd_same() {
let mut session = SessionDomain::new(
SessionId::new("test"),
AgentType::GeneralPurpose,
Model::Sonnet4,
);
session.working_directory = Some("/home/user/repo".to_string());
let data = make_status_data(Some("/home/user/repo"));
let changed = session.update_from_status_line(&data);
assert!(!changed, "should return false when cwd is the same");
}
#[test]
fn test_update_from_status_line_cwd_none_to_some() {
let mut session = SessionDomain::new(
SessionId::new("test"),
AgentType::GeneralPurpose,
Model::Sonnet4,
);
let data = make_status_data(Some("/home/user/repo"));
let changed = session.update_from_status_line(&data);
assert!(
changed,
"should return true when cwd goes from None to Some"
);
assert_eq!(
session.working_directory.as_deref(),
Some("/home/user/repo")
);
}
#[test]
fn test_update_from_status_line_cwd_some_to_none() {
let mut session = SessionDomain::new(
SessionId::new("test"),
AgentType::GeneralPurpose,
Model::Sonnet4,
);
session.working_directory = Some("/home/user/repo".to_string());
let data = make_status_data(None);
let changed = session.update_from_status_line(&data);
assert!(
!changed,
"should return false when incoming cwd is None (partial update)"
);
assert_eq!(
session.working_directory.as_deref(),
Some("/home/user/repo"),
"should preserve existing cwd when incoming is None"
);
}
}