use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use ratatui::style::Color;
use tokio_util::sync::CancellationToken;
use super::agent::{AgentModel, ReasoningLevel};
use crate::infra::agent::protocol::QuestionItem;
use crate::infra::channel::TurnPromptAttachment;
pub const SESSION_DATA_DIR: &str = ".agentty";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
New,
InProgress,
Review,
AgentReview,
Question,
Queued,
Rebasing,
Merging,
Done,
Canceled,
}
impl Status {
pub const ALL: [Status; 10] = [
Status::New,
Status::InProgress,
Status::Review,
Status::AgentReview,
Status::Question,
Status::Queued,
Status::Rebasing,
Status::Merging,
Status::Done,
Status::Canceled,
];
pub fn color(&self) -> Color {
match self {
Status::New => Color::DarkGray,
Status::InProgress => Color::Yellow,
Status::Review | Status::AgentReview => Color::LightBlue,
Status::Question => Color::LightMagenta,
Status::Queued => Color::LightCyan,
Status::Rebasing | Status::Merging => Color::Cyan,
Status::Done => Color::Green,
Status::Canceled => Color::Red,
}
}
pub fn allows_review_actions(self) -> bool {
matches!(self, Status::Review | Status::AgentReview)
}
pub fn can_transition_to(self, next: Status) -> bool {
if self == next {
return true;
}
matches!(
(self, next),
(Status::New, Status::InProgress)
| (Status::New | Status::InProgress, Status::Rebasing)
| (Status::Review, Status::AgentReview)
| (Status::AgentReview, Status::Review)
| (
Status::Review | Status::AgentReview | Status::Question,
Status::InProgress
| Status::Queued
| Status::Rebasing
| Status::Merging
| Status::Canceled
)
| (Status::Review | Status::AgentReview, Status::Done)
| (
Status::Queued,
Status::Merging | Status::Review | Status::AgentReview
)
| (
Status::InProgress | Status::Rebasing,
Status::Review | Status::AgentReview | Status::Question
)
| (
Status::Merging,
Status::Done | Status::Review | Status::AgentReview
)
)
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Status::New => write!(f, "New"),
Status::InProgress => write!(f, "InProgress"),
Status::Review => write!(f, "Review"),
Status::AgentReview => write!(f, "AgentReview"),
Status::Question => write!(f, "Question"),
Status::Queued => write!(f, "Queued"),
Status::Rebasing => write!(f, "Rebasing"),
Status::Merging => write!(f, "Merging"),
Status::Done => write!(f, "Done"),
Status::Canceled => write!(f, "Canceled"),
}
}
}
impl FromStr for Status {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"New" => Ok(Status::New),
"InProgress" | "Committing" => Ok(Status::InProgress),
"Review" => Ok(Status::Review),
"AgentReview" => Ok(Status::AgentReview),
"Question" => Ok(Status::Question),
"Queued" => Ok(Status::Queued),
"Rebasing" => Ok(Status::Rebasing),
"Merging" => Ok(Status::Merging),
"Done" => Ok(Status::Done),
"Canceled" => Ok(Status::Canceled),
_ => Err(format!("Unknown status: {s}")),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SessionSize {
#[default]
Xs,
S,
M,
L,
Xl,
Xxl,
}
impl SessionSize {
pub const ALL: [SessionSize; 6] = [
SessionSize::Xs,
SessionSize::S,
SessionSize::M,
SessionSize::L,
SessionSize::Xl,
SessionSize::Xxl,
];
pub fn from_diff(diff: &str) -> Self {
let (added_lines, deleted_lines) = SessionStats::line_change_counts(diff);
let changed_line_count =
usize::try_from(added_lines.saturating_add(deleted_lines)).unwrap_or(usize::MAX);
Self::from_changed_line_count(changed_line_count)
}
fn from_changed_line_count(changed_line_count: usize) -> Self {
match changed_line_count {
0..=10 => SessionSize::Xs,
11..=30 => SessionSize::S,
31..=80 => SessionSize::M,
81..=200 => SessionSize::L,
201..=500 => SessionSize::Xl,
_ => SessionSize::Xxl,
}
}
pub fn label(self) -> &'static str {
match self {
SessionSize::Xs => "XS",
SessionSize::S => "S",
SessionSize::M => "M",
SessionSize::L => "L",
SessionSize::Xl => "XL",
SessionSize::Xxl => "XXL",
}
}
}
impl fmt::Display for SessionSize {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.label())
}
}
impl FromStr for SessionSize {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"XS" | "Xs" | "xs" => Ok(SessionSize::Xs),
"S" | "s" => Ok(SessionSize::S),
"M" | "m" => Ok(SessionSize::M),
"L" | "l" => Ok(SessionSize::L),
"XL" | "Xl" | "xl" => Ok(SessionSize::Xl),
"XXL" | "Xxl" | "xxl" => Ok(SessionSize::Xxl),
_ => Err(format!("Unknown session size: {s}")),
}
}
}
pub use ag_forge::ForgeKind;
pub use ag_forge::ReviewRequestState;
pub use ag_forge::ReviewRequestSummary;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewRequest {
pub last_refreshed_at: i64,
pub summary: ReviewRequestSummary,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PublishBranchAction {
Push,
PublishPullRequest,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FollowUpTaskAction {
Launch,
Open,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PublishedBranchSyncStatus {
#[default]
Idle,
InProgress,
Succeeded,
Failed,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SessionStats {
pub added_lines: u64,
pub deleted_lines: u64,
pub input_tokens: u64,
pub output_tokens: u64,
}
impl SessionStats {
pub fn line_change_counts(diff: &str) -> (u64, u64) {
diff.lines()
.fold((0_u64, 0_u64), |(added_lines, deleted_lines), line| {
if line.starts_with('+') && !line.starts_with("+++") {
return (added_lines.saturating_add(1), deleted_lines);
}
if line.starts_with('-') && !line.starts_with("---") {
return (added_lines, deleted_lines.saturating_add(1));
}
(added_lines, deleted_lines)
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DailyActivity {
pub day_key: i64,
pub session_count: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionFollowUpTask {
pub id: i64,
pub launched_session_id: Option<String>,
pub position: usize,
pub text: String,
}
impl SessionFollowUpTask {
pub fn action(&self) -> FollowUpTaskAction {
if self.launched_session_id.is_some() {
return FollowUpTaskAction::Open;
}
FollowUpTaskAction::Launch
}
}
pub struct Session {
pub base_branch: String,
pub created_at: i64,
pub draft_attachments: Vec<TurnPromptAttachment>,
pub folder: PathBuf,
pub follow_up_tasks: Vec<SessionFollowUpTask>,
pub id: String,
pub in_progress_started_at: Option<i64>,
pub in_progress_total_seconds: i64,
pub is_draft: bool,
pub model: AgentModel,
pub output: String,
pub project_name: String,
pub prompt: String,
pub reasoning_level_override: Option<ReasoningLevel>,
pub published_upstream_ref: Option<String>,
pub published_branch_sync_status: PublishedBranchSyncStatus,
pub questions: Vec<QuestionItem>,
pub review_request: Option<ReviewRequest>,
pub size: SessionSize,
pub stats: SessionStats,
pub status: Status,
pub summary: Option<String>,
pub title: Option<String>,
pub updated_at: i64,
}
impl Session {
pub fn display_title(&self) -> &str {
self.title.as_deref().unwrap_or("No title")
}
pub fn is_draft_session(&self) -> bool {
self.is_draft
}
pub fn has_staged_drafts(&self) -> bool {
self.is_draft_session() && self.status == Status::New && !self.prompt.is_empty()
}
pub fn has_in_progress_timer(&self) -> bool {
self.in_progress_total_seconds > 0 || self.in_progress_started_at.is_some()
}
pub fn effective_reasoning_level(
&self,
default_reasoning_level: ReasoningLevel,
) -> ReasoningLevel {
self.reasoning_level_override
.unwrap_or(default_reasoning_level)
}
pub fn uses_default_reasoning_level(&self) -> bool {
self.reasoning_level_override.is_none()
}
pub fn in_progress_duration_seconds(&self, wall_clock_unix_seconds: i64) -> i64 {
let open_interval_seconds = self.in_progress_started_at.map_or(0, |started_at| {
wall_clock_unix_seconds.saturating_sub(started_at).max(0)
});
self.in_progress_total_seconds
.saturating_add(open_interval_seconds)
}
pub fn forge_indicator(&self) -> String {
if let Some(review_request) = &self.review_request {
let display_id = &review_request.summary.display_id;
return match review_request.summary.state {
ReviewRequestState::Open => format!("⊙ {display_id}"),
ReviewRequestState::Merged => format!("✓ {display_id}"),
ReviewRequestState::Closed => format!("✗ {display_id}"),
};
}
if self.published_upstream_ref.is_some() {
return "↑".to_string();
}
String::new()
}
pub fn can_sync_review_request(&self) -> bool {
let has_forge_context =
self.published_upstream_ref.is_some() || self.review_request.is_some();
has_forge_context && matches!(self.status, Status::Review | Status::AgentReview)
}
pub fn published_branch_sync_message(&self) -> Option<&'static str> {
self.published_upstream_ref.as_ref()?;
match self.published_branch_sync_status {
PublishedBranchSyncStatus::Idle => None,
PublishedBranchSyncStatus::InProgress => {
Some("Auto-pushing published branch after completed turn...")
}
PublishedBranchSyncStatus::Succeeded => {
Some("Auto-pushed published branch after completed turn.")
}
PublishedBranchSyncStatus::Failed => Some("Published branch sync failed."),
}
}
pub fn publish_pull_request_action(&self) -> Option<PublishBranchAction> {
self.status
.allows_review_actions()
.then_some(PublishBranchAction::PublishPullRequest)
}
pub fn follow_up_task(&self, position: usize) -> Option<&SessionFollowUpTask> {
self.follow_up_tasks
.iter()
.find(|task| task.position == position)
}
}
pub struct SessionHandles {
pub cancel_token: Arc<Mutex<CancellationToken>>,
pub child_pid: Arc<Mutex<Option<u32>>>,
pub output: Arc<Mutex<String>>,
pub status: Arc<Mutex<Status>>,
}
impl SessionHandles {
pub fn new(output: String, status: Status) -> Self {
Self {
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
child_pid: Arc::new(Mutex::new(None)),
output: Arc::new(Mutex::new(output)),
status: Arc::new(Mutex::new(status)),
}
}
pub fn append_output(&self, message: &str) {
if let Ok(mut buf) = self.output.lock() {
buf.push_str(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_session(reasoning_level_override: Option<ReasoningLevel>) -> Session {
Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override,
published_upstream_ref: None,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Review,
summary: None,
title: None,
updated_at: 0,
}
}
#[test]
fn test_status_from_str_queued() {
let raw_status = "Queued";
let status = raw_status
.parse::<Status>()
.expect("failed to parse status");
assert_eq!(status, Status::Queued);
}
#[test]
fn test_status_display_queued() {
let status = Status::Queued;
let displayed_status = status.to_string();
assert_eq!(displayed_status, "Queued");
}
#[test]
fn test_status_all_lists_every_supported_status_in_display_order() {
let expected_statuses = [
Status::New,
Status::InProgress,
Status::Review,
Status::AgentReview,
Status::Question,
Status::Queued,
Status::Rebasing,
Status::Merging,
Status::Done,
Status::Canceled,
];
let all_statuses = Status::ALL;
assert_eq!(all_statuses, expected_statuses);
}
#[test]
fn test_status_transition_review_to_queued() {
let current_status = Status::Review;
let can_transition = current_status.can_transition_to(Status::Queued);
assert!(can_transition);
}
#[test]
fn test_status_transition_review_to_agent_review() {
let current_status = Status::Review;
let can_transition = current_status.can_transition_to(Status::AgentReview);
assert!(can_transition);
}
#[test]
fn test_status_allows_review_actions_for_agent_review() {
let status = Status::AgentReview;
let allows_review_actions = status.allows_review_actions();
assert!(allows_review_actions);
}
#[test]
fn test_status_transition_queued_to_merging() {
let current_status = Status::Queued;
let can_transition = current_status.can_transition_to(Status::Merging);
assert!(can_transition);
}
#[test]
fn test_status_transition_queued_to_in_progress_is_rejected() {
let current_status = Status::Queued;
let can_transition = current_status.can_transition_to(Status::InProgress);
assert!(!can_transition);
}
#[test]
fn test_session_stats_line_change_counts_ignore_diff_headers() {
let diff = "\
diff --git a/src/lib.rs b/src/lib.rs\nindex 1111111..2222222 100644\n--- a/src/lib.rs\n+++ \
b/src/lib.rs\n@@ -1,2 +1,3 @@\n-old line\n+new line\n+another line\n";
let (added_lines, deleted_lines) = SessionStats::line_change_counts(diff);
assert_eq!(added_lines, 2);
assert_eq!(deleted_lines, 1);
}
#[test]
fn test_session_size_from_diff_counts_added_and_deleted_lines() {
let diff = "\
diff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1,2 @@\n-old line\n+new line\n+another line\n";
let session_size = SessionSize::from_diff(diff);
assert_eq!(session_size, SessionSize::Xs);
}
#[test]
fn test_effective_reasoning_level_uses_default_when_override_is_missing() {
let session = test_session(None);
let effective_reasoning_level = session.effective_reasoning_level(ReasoningLevel::Medium);
let uses_default_reasoning_level = session.uses_default_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::Medium);
assert!(uses_default_reasoning_level);
}
#[test]
fn test_effective_reasoning_level_prefers_session_override() {
let session = test_session(Some(ReasoningLevel::High));
let effective_reasoning_level = session.effective_reasoning_level(ReasoningLevel::Low);
let uses_default_reasoning_level = session.uses_default_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::High);
assert!(!uses_default_reasoning_level);
}
#[test]
fn test_effective_reasoning_level_returns_to_default_after_override_is_cleared() {
let mut session = test_session(Some(ReasoningLevel::XHigh));
session.reasoning_level_override = None;
let effective_reasoning_level = session.effective_reasoning_level(ReasoningLevel::Low);
let uses_default_reasoning_level = session.uses_default_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::Low);
assert!(uses_default_reasoning_level);
}
#[test]
fn test_forge_kind_from_str_github() {
let raw_forge_kind = "GitHub";
let forge_kind = raw_forge_kind
.parse::<ForgeKind>()
.expect("failed to parse review-request forge");
assert_eq!(forge_kind, ForgeKind::GitHub);
}
#[test]
fn test_forge_kind_from_str_gitlab() {
let raw_forge_kind = "GitLab";
let forge_kind = raw_forge_kind
.parse::<ForgeKind>()
.expect("failed to parse review-request forge");
assert_eq!(forge_kind, ForgeKind::GitLab);
}
#[test]
fn test_review_request_state_display_merged() {
let review_request_state = ReviewRequestState::Merged;
let displayed_state = review_request_state.to_string();
assert_eq!(displayed_state, "Merged");
}
#[test]
fn test_publish_pull_request_action_returns_publish_for_review_session() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Review,
summary: None,
title: None,
updated_at: 0,
};
let action = session.publish_pull_request_action();
assert_eq!(action, Some(PublishBranchAction::PublishPullRequest));
}
#[test]
fn test_publish_pull_request_action_returns_publish_for_agent_review_session() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::AgentReview,
summary: None,
title: None,
updated_at: 0,
};
let action = session.publish_pull_request_action();
assert_eq!(action, Some(PublishBranchAction::PublishPullRequest));
}
#[test]
fn test_publish_pull_request_action_returns_none_for_in_progress_session() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
in_progress_started_at: Some(60),
in_progress_total_seconds: 120,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: Some("origin/agentty/session-id".to_string()),
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
updated_at: 0,
};
let action = session.publish_pull_request_action();
assert_eq!(action, None);
}
#[test]
fn test_publish_pull_request_action_returns_none_for_done_session() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: "session-id".to_string(),
in_progress_started_at: None,
in_progress_total_seconds: 180,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: Some("origin/agentty/session-id".to_string()),
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Done,
summary: None,
title: None,
updated_at: 0,
};
let action = session.publish_pull_request_action();
assert_eq!(action, None);
}
#[test]
fn test_has_in_progress_timer_returns_true_for_open_interval() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
id: "session-id".to_string(),
in_progress_started_at: Some(120),
in_progress_total_seconds: 0,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
follow_up_tasks: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
updated_at: 0,
};
let shows_timer = session.has_in_progress_timer();
assert!(shows_timer);
}
#[test]
fn test_in_progress_duration_seconds_accumulates_closed_and_open_intervals() {
let session = Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
id: "session-id".to_string(),
in_progress_started_at: Some(200),
in_progress_total_seconds: 90,
is_draft: false,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: "project".to_string(),
prompt: String::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
follow_up_tasks: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
updated_at: 0,
};
let duration_seconds = session.in_progress_duration_seconds(260);
assert_eq!(duration_seconds, 150);
}
#[test]
fn test_forge_indicator_returns_open_symbol_with_display_id() {
let mut session = test_session(None);
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "agentty/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
let indicator = session.forge_indicator();
assert_eq!(indicator, "⊙ #42");
}
#[test]
fn test_forge_indicator_returns_merged_symbol_with_display_id() {
let mut session = test_session(None);
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#99".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "agentty/session-id".to_string(),
state: ReviewRequestState::Merged,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
let indicator = session.forge_indicator();
assert_eq!(indicator, "✓ #99");
}
#[test]
fn test_forge_indicator_returns_closed_symbol_with_display_id() {
let mut session = test_session(None);
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#7".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "agentty/session-id".to_string(),
state: ReviewRequestState::Closed,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
let indicator = session.forge_indicator();
assert_eq!(indicator, "✗ #7");
}
#[test]
fn test_forge_indicator_returns_arrow_for_published_branch_without_review_request() {
let mut session = test_session(None);
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
let indicator = session.forge_indicator();
assert_eq!(indicator, "↑");
}
#[test]
fn test_forge_indicator_returns_empty_when_no_forge_context() {
let session = test_session(None);
let indicator = session.forge_indicator();
assert_eq!(indicator, "");
}
#[test]
fn test_forge_indicator_prefers_review_request_over_published_ref() {
let mut session = test_session(None);
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#10".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "agentty/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
let indicator = session.forge_indicator();
assert_eq!(indicator, "⊙ #10");
}
#[test]
fn test_can_sync_review_request_true_for_review_with_published_ref() {
let mut session = test_session(None);
session.status = Status::Review;
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
assert!(session.can_sync_review_request());
}
#[test]
fn test_can_sync_review_request_true_for_agent_review_with_review_request() {
let mut session = test_session(None);
session.status = Status::AgentReview;
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#1".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "agentty/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "feat".to_string(),
web_url: String::new(),
},
});
assert!(session.can_sync_review_request());
}
#[test]
fn test_can_sync_review_request_false_for_question_with_published_ref() {
let mut session = test_session(None);
session.status = Status::Question;
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
assert!(!session.can_sync_review_request());
}
#[test]
fn test_can_sync_review_request_false_for_in_progress() {
let mut session = test_session(None);
session.status = Status::InProgress;
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
assert!(!session.can_sync_review_request());
}
#[test]
fn test_can_sync_review_request_false_for_done() {
let mut session = test_session(None);
session.status = Status::Done;
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
assert!(!session.can_sync_review_request());
}
#[test]
fn test_can_sync_review_request_false_without_forge_context() {
let mut session = test_session(None);
session.status = Status::Review;
assert!(!session.can_sync_review_request());
}
#[test]
fn test_published_branch_sync_message_returns_in_progress_copy() {
let mut session = test_session(None);
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.published_branch_sync_status = PublishedBranchSyncStatus::InProgress;
let sync_message = session.published_branch_sync_message();
assert_eq!(
sync_message,
Some("Auto-pushing published branch after completed turn...")
);
}
#[test]
fn test_published_branch_sync_message_returns_succeeded_copy() {
let mut session = test_session(None);
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.published_branch_sync_status = PublishedBranchSyncStatus::Succeeded;
let sync_message = session.published_branch_sync_message();
assert_eq!(
sync_message,
Some("Auto-pushed published branch after completed turn.")
);
}
#[test]
fn test_published_branch_sync_message_returns_failed_copy() {
let mut session = test_session(None);
session.published_upstream_ref = Some("origin/agentty/session-id".to_string());
session.published_branch_sync_status = PublishedBranchSyncStatus::Failed;
let sync_message = session.published_branch_sync_message();
assert_eq!(sync_message, Some("Published branch sync failed."));
}
#[test]
fn test_status_transition_review_to_done() {
let current_status = Status::Review;
let can_transition = current_status.can_transition_to(Status::Done);
assert!(can_transition);
}
#[test]
fn test_status_transition_agent_review_to_done() {
let current_status = Status::AgentReview;
let can_transition = current_status.can_transition_to(Status::Done);
assert!(can_transition);
}
#[test]
fn test_status_transition_question_to_done_rejected() {
let current_status = Status::Question;
let can_transition = current_status.can_transition_to(Status::Done);
assert!(!can_transition);
}
}