use std::borrow::Borrow;
use std::collections::VecDeque;
use std::fmt;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use serde::de::{self, Deserializer};
use serde::ser::Serializer;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use super::agent::{AgentSelection, ReasoningLevel};
use crate::domain::question::QuestionItem;
use crate::domain::turn_prompt::{TurnPrompt, TurnPromptAttachment};
pub const SESSION_DATA_DIR: &str = ".agentty";
pub(crate) const COMMITTING_PROGRESS_LABEL: &str = "Committing...";
const TERMINAL_CONTINUATION_PROMPT_INTRO: &str =
"Continue the work from this previous Agentty session.";
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SessionId(Arc<str>);
impl SessionId {
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl AsRef<Path> for SessionId {
fn as_ref(&self) -> &Path {
Path::new(self.as_str())
}
}
impl Borrow<str> for SessionId {
fn borrow(&self) -> &str {
self.as_str()
}
}
impl Deref for SessionId {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_str()
}
}
impl fmt::Display for SessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl From<&str> for SessionId {
fn from(value: &str) -> Self {
Self(Arc::<str>::from(value))
}
}
impl From<String> for SessionId {
fn from(value: String) -> Self {
Self(Arc::<str>::from(value))
}
}
impl From<Arc<str>> for SessionId {
fn from(value: Arc<str>) -> Self {
Self(value)
}
}
impl From<SessionId> for String {
fn from(value: SessionId) -> Self {
value.as_str().to_string()
}
}
impl PartialEq<str> for SessionId {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for SessionId {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl PartialEq<String> for SessionId {
fn eq(&self, other: &String) -> bool {
self.as_str() == other
}
}
impl PartialEq<&String> for SessionId {
fn eq(&self, other: &&String) -> bool {
self.as_str() == other.as_str()
}
}
impl Serialize for SessionId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SessionId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)
.map(Self::from)
.map_err(de::Error::custom)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
Draft,
InProgress,
Review,
AgentReview,
Question,
Queued,
Rebasing,
Merging,
Done,
Canceled,
}
impl Status {
pub const ALL: [Status; 10] = [
Status::Draft,
Status::InProgress,
Status::Review,
Status::AgentReview,
Status::Question,
Status::Queued,
Status::Rebasing,
Status::Merging,
Status::Done,
Status::Canceled,
];
pub fn allows_review_actions(self) -> bool {
matches!(self, Status::Review | Status::AgentReview)
}
pub fn allows_terminal_continuation(self) -> bool {
matches!(self, Status::Done)
}
pub fn allows_stacked_child_start(self) -> bool {
self.allows_review_actions()
}
pub fn is_stack_branch_mutating(self) -> bool {
matches!(
self,
Status::InProgress
| Status::Question
| Status::Queued
| Status::Rebasing
| Status::Merging
)
}
pub fn can_transition_to(self, next: Status) -> bool {
if self == next {
return true;
}
matches!(
(self, next),
(Status::Draft, Status::InProgress | Status::Canceled)
| (Status::Draft | Status::InProgress, Status::Rebasing)
| (Status::InProgress, Status::Canceled)
| (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::Draft => write!(f, "Draft"),
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 {
"Draft" => Ok(Status::Draft),
"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<SessionId>,
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 agent: AgentSelection,
pub base_branch: String,
pub created_at: i64,
pub draft_attachments: Vec<TurnPromptAttachment>,
pub folder: PathBuf,
pub follow_up_tasks: Vec<SessionFollowUpTask>,
pub id: SessionId,
pub in_progress_started_at: Option<i64>,
pub in_progress_total_seconds: i64,
pub is_draft: bool,
pub output: String,
pub parent_session_id: Option<SessionId>,
pub project_name: String,
pub prompt: String,
pub queued_messages: Vec<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,
pub workflow_notice: Option<String>,
}
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::Draft && !self.prompt.is_empty()
}
pub fn allows_stacked_child_creation(&self) -> bool {
self.parent_session_id.is_none()
&& !self.is_draft_session()
&& !matches!(self.status, Status::Done | Status::Canceled)
}
pub fn is_stacked_child(&self) -> bool {
self.parent_session_id.is_some()
}
pub fn can_start_staged_session(&self) -> bool {
self.is_draft_session() && self.status == Status::Draft && self.has_staged_drafts()
}
pub fn allows_cancel_action(&self) -> bool {
self.status == Status::InProgress
|| self.status.allows_review_actions()
|| (self.status == Status::Draft && self.is_draft_session())
}
pub fn allows_terminal_continuation(&self) -> bool {
self.status.allows_terminal_continuation()
}
pub fn continuation_prompt_seed(&self) -> Option<String> {
if !self.allows_terminal_continuation() {
return None;
}
let (context_label, context_text) = self.continuation_context()?;
Some(format!(
"{TERMINAL_CONTINUATION_PROMPT_INTRO}\n\nPrevious session: {}\nProject: {}\nStatus: \
{}\n\n{context_label}:\n{context_text}\n",
self.display_title(),
self.project_name,
self.status,
))
}
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)
}
fn continuation_context(&self) -> Option<(&'static str, &str)> {
self.non_empty_summary()
.map(|summary| ("Previous session summary", summary))
.or_else(|| {
self.non_empty_output()
.map(|output| ("Previous session transcript", output))
})
.or_else(|| {
self.non_empty_prompt()
.map(|prompt| ("Previous session prompt", prompt))
})
}
fn non_empty_summary(&self) -> Option<&str> {
self.summary
.as_deref()
.and_then(Self::trimmed_non_empty_text)
}
fn non_empty_output(&self) -> Option<&str> {
Self::trimmed_non_empty_text(&self.output)
}
fn non_empty_prompt(&self) -> Option<&str> {
Self::trimmed_non_empty_text(&self.prompt)
}
fn trimmed_non_empty_text(value: &str) -> Option<&str> {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
}
pub(crate) fn can_start_staged_session_in_stack(sessions: &[Session], session_id: &str) -> bool {
let Some(stack) = SessionStack::for_session(sessions, session_id) else {
return false;
};
let session = stack.requested_session();
if !session.can_start_staged_session() {
return false;
}
if session.parent_session_id.is_none() {
return true;
}
if !stack.root_allows_stacked_child_start() {
return false;
}
!stack.has_branch_mutating_member_except(session_id)
}
pub(crate) fn can_mutate_session_branch_in_stack(sessions: &[Session], session_id: &str) -> bool {
let Some(stack) = SessionStack::for_session(sessions, session_id) else {
return false;
};
if stack.has_branch_mutating_member_except(session_id) {
return false;
}
if stack.requested_session_is_root() && stack.has_materialized_child() {
return false;
}
true
}
pub(crate) fn can_rebase_session_branch_in_stack(sessions: &[Session], session_id: &str) -> bool {
let Some(stack) = SessionStack::for_session(sessions, session_id) else {
return false;
};
!stack.has_branch_mutating_member_except(session_id)
}
pub(crate) fn can_reply_to_session_in_stack(sessions: &[Session], session_id: &str) -> bool {
let Some(stack) = SessionStack::for_session(sessions, session_id) else {
return false;
};
!stack.has_branch_mutating_member_except(session_id)
}
struct SessionStack<'a> {
members: Vec<&'a Session>,
requested_session: &'a Session,
root_session: &'a Session,
}
impl<'a> SessionStack<'a> {
fn for_session(sessions: &'a [Session], session_id: &str) -> Option<Self> {
let requested_session = find_session(sessions, session_id)?;
let root_session = match requested_session.parent_session_id.as_ref() {
Some(parent_session_id) => find_session(sessions, parent_session_id.as_str())?,
None => requested_session,
};
let members = sessions
.iter()
.filter(|session| Self::session_belongs_to_root(session, root_session.id.as_str()))
.collect();
Some(Self {
members,
requested_session,
root_session,
})
}
fn requested_session(&self) -> &'a Session {
self.requested_session
}
fn requested_session_is_root(&self) -> bool {
self.requested_session.parent_session_id.is_none()
}
fn has_branch_mutating_member_except(&self, ignored_session_id: &str) -> bool {
self.members
.iter()
.filter(|session| session.id.as_str() != ignored_session_id)
.any(|session| session.status.is_stack_branch_mutating())
}
fn has_materialized_child(&self) -> bool {
self.members.iter().any(|session| {
session.parent_session_id.is_some()
&& !matches!(
session.status,
Status::Draft | Status::Done | Status::Canceled
)
})
}
fn root_allows_stacked_child_start(&self) -> bool {
self.root_session.status.allows_stacked_child_start()
}
fn session_belongs_to_root(session: &Session, root_session_id: &str) -> bool {
session.id.as_str() == root_session_id
|| session
.parent_session_id
.as_ref()
.is_some_and(|parent_session_id| parent_session_id.as_str() == root_session_id)
}
}
fn find_session<'a>(sessions: &'a [Session], session_id: &str) -> Option<&'a Session> {
sessions
.iter()
.find(|session| session.id.as_str() == session_id)
}
pub struct SessionHandles {
pub cancel_token: Arc<Mutex<CancellationToken>>,
pub child_pid: Arc<Mutex<Option<u32>>>,
pub output: Arc<Mutex<String>>,
pub queued_messages: Arc<Mutex<VecDeque<TurnPrompt>>>,
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)),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
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);
}
}
pub fn queued_message_transcripts(&self) -> Vec<String> {
self.queued_messages
.lock()
.map(|guard| {
guard
.iter()
.map(TurnPrompt::transcript_text)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use super::*;
use crate::domain::agent::AgentModel;
pub(crate) struct SessionFixtureBuilder {
session: Session,
}
impl SessionFixtureBuilder {
pub(crate) fn new() -> Self {
Self {
session: Session {
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::new(),
follow_up_tasks: Vec::new(),
id: SessionId::from("session-id"),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::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,
workflow_notice: None,
},
}
}
pub(crate) fn id(mut self, id: impl Into<SessionId>) -> Self {
self.session.id = id.into();
self
}
pub(crate) fn status(mut self, status: Status) -> Self {
self.session.status = status;
self
}
pub(crate) fn draft(mut self, is_draft: bool) -> Self {
self.session.is_draft = is_draft;
self
}
pub(crate) fn folder(mut self, folder: PathBuf) -> Self {
self.session.folder = folder;
self
}
pub(crate) fn project_name(mut self, project_name: impl Into<String>) -> Self {
self.session.project_name = project_name.into();
self
}
pub(crate) fn model(mut self, model: AgentModel) -> Self {
self.session.agent = AgentSelection::new(self.session.agent.kind(), model);
self
}
pub(crate) fn output(mut self, output: impl Into<String>) -> Self {
self.session.output = output.into();
self
}
pub(crate) fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.session.prompt = prompt.into();
self
}
pub(crate) fn parent_session_id(mut self, parent_session_id: Option<SessionId>) -> Self {
self.session.parent_session_id = parent_session_id;
self
}
pub(crate) fn title(mut self, title: Option<String>) -> Self {
self.session.title = title;
self
}
pub(crate) fn summary(mut self, summary: Option<String>) -> Self {
self.session.summary = summary;
self
}
pub(crate) fn reasoning_level_override(
mut self,
reasoning_level_override: Option<ReasoningLevel>,
) -> Self {
self.session.reasoning_level_override = reasoning_level_override;
self
}
pub(crate) fn build(self) -> Session {
self.session
}
}
#[test]
fn test_allows_stacked_child_creation_returns_true_for_root_active_session() {
let session = SessionFixtureBuilder::new()
.draft(false)
.status(Status::Review)
.build();
let allows_stacked_child = session.allows_stacked_child_creation();
assert!(allows_stacked_child);
}
#[test]
fn test_allows_stacked_child_creation_rejects_drafts_children_and_terminal_sessions() {
let draft_session = SessionFixtureBuilder::new()
.draft(true)
.status(Status::Draft)
.build();
let child_session = SessionFixtureBuilder::new()
.parent_session_id(Some(SessionId::from("parent-session")))
.status(Status::Review)
.build();
let done_session = SessionFixtureBuilder::new().status(Status::Done).build();
let canceled_session = SessionFixtureBuilder::new()
.status(Status::Canceled)
.build();
let allows_draft_child = draft_session.allows_stacked_child_creation();
let allows_nested_child = child_session.allows_stacked_child_creation();
let allows_done_child = done_session.allows_stacked_child_creation();
let allows_canceled_child = canceled_session.allows_stacked_child_creation();
assert!(!allows_draft_child);
assert!(!allows_nested_child);
assert!(!allows_done_child);
assert!(!allows_canceled_child);
}
#[test]
fn test_can_start_staged_session_checks_only_draft_readiness() {
let root_draft_session = SessionFixtureBuilder::new()
.draft(true)
.status(Status::Draft)
.prompt("Ready to start")
.build();
let stacked_draft_session = SessionFixtureBuilder::new()
.draft(true)
.status(Status::Draft)
.prompt("Waiting on parent")
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let can_start_root_draft = root_draft_session.can_start_staged_session();
let can_start_stacked_draft = stacked_draft_session.can_start_staged_session();
assert!(can_start_root_draft);
assert!(can_start_stacked_draft);
}
#[test]
fn test_can_start_staged_session_in_stack_requires_parent_review() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::InProgress)
.build();
let child_session = SessionFixtureBuilder::new()
.id("child-session")
.draft(true)
.status(Status::Draft)
.prompt("Ready child draft")
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, child_session];
let can_start_child = can_start_staged_session_in_stack(&sessions, "child-session");
assert!(!can_start_child);
}
#[test]
fn test_can_start_staged_session_in_stack_blocks_active_stack_member() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let running_child_session = SessionFixtureBuilder::new()
.id("running-child")
.draft(true)
.status(Status::InProgress)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let staged_child_session = SessionFixtureBuilder::new()
.id("staged-child")
.draft(true)
.status(Status::Draft)
.prompt("Ready child draft")
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, running_child_session, staged_child_session];
let can_start_child = can_start_staged_session_in_stack(&sessions, "staged-child");
assert!(!can_start_child);
}
#[test]
fn test_can_start_staged_session_in_stack_allows_review_ready_parent() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let child_session = SessionFixtureBuilder::new()
.id("child-session")
.draft(true)
.status(Status::Draft)
.prompt("Ready child draft")
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, child_session];
let can_start_child = can_start_staged_session_in_stack(&sessions, "child-session");
assert!(can_start_child);
}
#[test]
fn test_can_mutate_session_branch_in_stack_blocks_parent_with_materialized_child() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let child_session = SessionFixtureBuilder::new()
.id("child-session")
.draft(true)
.status(Status::Review)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, child_session];
let can_mutate_parent = can_mutate_session_branch_in_stack(&sessions, "parent-session");
assert!(!can_mutate_parent);
}
#[test]
fn test_can_mutate_session_branch_in_stack_blocks_concurrent_stack_member() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let running_child_session = SessionFixtureBuilder::new()
.id("running-child")
.draft(true)
.status(Status::InProgress)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let review_child_session = SessionFixtureBuilder::new()
.id("review-child")
.draft(true)
.status(Status::Review)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, running_child_session, review_child_session];
let can_mutate_review_child = can_mutate_session_branch_in_stack(&sessions, "review-child");
assert!(!can_mutate_review_child);
}
#[test]
fn test_can_rebase_session_branch_in_stack_allows_parent_with_review_child() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let child_session = SessionFixtureBuilder::new()
.id("child-session")
.draft(true)
.status(Status::Review)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, child_session];
let can_rebase_parent = can_rebase_session_branch_in_stack(&sessions, "parent-session");
assert!(can_rebase_parent);
}
#[test]
fn test_can_rebase_session_branch_in_stack_blocks_concurrent_stack_member() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let running_child_session = SessionFixtureBuilder::new()
.id("running-child")
.draft(true)
.status(Status::InProgress)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let review_child_session = SessionFixtureBuilder::new()
.id("review-child")
.draft(true)
.status(Status::Review)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, running_child_session, review_child_session];
let can_rebase_review_child = can_rebase_session_branch_in_stack(&sessions, "review-child");
assert!(!can_rebase_review_child);
}
#[test]
fn test_can_reply_to_session_in_stack_allows_parent_with_review_child() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let child_session = SessionFixtureBuilder::new()
.id("child-session")
.draft(true)
.status(Status::Review)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, child_session];
let can_reply_to_parent = can_reply_to_session_in_stack(&sessions, "parent-session");
assert!(can_reply_to_parent);
}
#[test]
fn test_can_reply_to_session_in_stack_blocks_active_stack_member() {
let parent_session = SessionFixtureBuilder::new()
.id("parent-session")
.draft(false)
.status(Status::Review)
.build();
let running_child_session = SessionFixtureBuilder::new()
.id("running-child")
.draft(true)
.status(Status::InProgress)
.parent_session_id(Some(SessionId::from("parent-session")))
.build();
let sessions = vec![parent_session, running_child_session];
let can_reply_to_parent = can_reply_to_session_in_stack(&sessions, "parent-session");
assert!(!can_reply_to_parent);
}
fn test_session(reasoning_level_override: Option<ReasoningLevel>) -> Session {
SessionFixtureBuilder::new()
.reasoning_level_override(reasoning_level_override)
.build()
}
#[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_from_str_draft() {
let raw_status = "Draft";
let status = raw_status
.parse::<Status>()
.expect("failed to parse status");
assert_eq!(status, Status::Draft);
}
#[test]
fn test_status_display_draft() {
let status = Status::Draft;
let displayed_status = status.to_string();
assert_eq!(displayed_status, "Draft");
}
#[test]
fn test_session_id_hash_map_borrowed_lookup() {
let session_id = SessionId::from("session-id");
let sessions = HashMap::from([(session_id, "ready")]);
let status = sessions.get("session-id");
assert_eq!(status, Some(&"ready"));
}
#[test]
fn test_session_id_serde_serializes_as_plain_string() {
let session_id = SessionId::from("session-id");
let serialized_session_id =
serde_json::to_string(&session_id).expect("session id should serialize");
let deserialized_session_id: SessionId =
serde_json::from_str(&serialized_session_id).expect("session id should deserialize");
assert_eq!(serialized_session_id, "\"session-id\"");
assert_eq!(deserialized_session_id, session_id);
}
#[test]
fn test_status_all_lists_every_supported_status_in_display_order() {
let expected_statuses = [
Status::Draft,
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_allows_terminal_continuation_only_for_done() {
let done_status = Status::Done;
let canceled_status = Status::Canceled;
let review_status = Status::Review;
let done_allows_continuation = done_status.allows_terminal_continuation();
let canceled_allows_continuation = canceled_status.allows_terminal_continuation();
let review_allows_continuation = review_status.allows_terminal_continuation();
assert!(done_allows_continuation);
assert!(!canceled_allows_continuation);
assert!(!review_allows_continuation);
}
#[test]
fn test_status_transition_draft_to_canceled() {
let current_status = Status::Draft;
let can_transition = current_status.can_transition_to(Status::Canceled);
assert!(can_transition);
}
#[test]
fn test_status_transition_in_progress_to_canceled() {
let current_status = Status::InProgress;
let can_transition = current_status.can_transition_to(Status::Canceled);
assert!(can_transition);
}
#[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_session_continuation_prompt_seed_prefers_summary_for_terminal_session() {
let session = SessionFixtureBuilder::new()
.status(Status::Done)
.project_name("project-alpha")
.summary(Some("# Summary\n\nShip it.".to_string()))
.output("assistant transcript")
.title(Some("Terminal session".to_string()))
.build();
let continuation_prompt_seed = session
.continuation_prompt_seed()
.expect("expected continuation prompt seed");
assert!(continuation_prompt_seed.contains(TERMINAL_CONTINUATION_PROMPT_INTRO));
assert!(continuation_prompt_seed.contains("Previous session: Terminal session"));
assert!(continuation_prompt_seed.contains("Project: project-alpha"));
assert!(continuation_prompt_seed.contains("Status: Done"));
assert!(continuation_prompt_seed.contains("Previous session summary:\n# Summary"));
assert!(!continuation_prompt_seed.contains("assistant transcript"));
}
#[test]
fn test_session_continuation_prompt_seed_disabled_for_canceled_session() {
let session = SessionFixtureBuilder::new()
.status(Status::Canceled)
.project_name("project-beta")
.build();
let continuation_prompt_seed = session.continuation_prompt_seed();
assert_eq!(continuation_prompt_seed, None);
}
#[test]
fn test_session_continuation_prompt_seed_rejects_non_terminal_session() {
let session = SessionFixtureBuilder::new()
.status(Status::Review)
.summary(Some("summary".to_string()))
.build();
let continuation_prompt_seed = session.continuation_prompt_seed();
assert_eq!(continuation_prompt_seed, None);
}
#[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".into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::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,
workflow_notice: None,
};
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".into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::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,
workflow_notice: None,
};
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".into(),
in_progress_started_at: Some(60),
in_progress_total_seconds: 120,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: Some("origin/wt/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,
workflow_notice: None,
};
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".into(),
in_progress_started_at: None,
in_progress_total_seconds: 180,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: Some("origin/wt/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,
workflow_notice: None,
};
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(),
follow_up_tasks: Vec::new(),
id: "session-id".into(),
in_progress_started_at: Some(120),
in_progress_total_seconds: 0,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::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::InProgress,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
};
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(),
follow_up_tasks: Vec::new(),
id: "session-id".into(),
in_progress_started_at: Some(200),
in_progress_total_seconds: 90,
is_draft: false,
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini3FlashPreview,
),
output: String::new(),
parent_session_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::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::InProgress,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
};
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: "wt/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: "wt/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: "wt/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/wt/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/wt/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: "wt/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/wt/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: "wt/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/wt/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/wt/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/wt/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/wt/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/wt/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/wt/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_session_allows_cancel_action_for_unstarted_draft_session() {
let mut session = test_session(None);
session.status = Status::Draft;
session.is_draft = true;
let allows_cancel_action = session.allows_cancel_action();
assert!(allows_cancel_action);
}
#[test]
fn test_session_allows_cancel_action_for_running_session() {
let mut session = test_session(None);
session.status = Status::InProgress;
let allows_cancel_action = session.allows_cancel_action();
assert!(allows_cancel_action);
}
#[test]
fn test_session_allows_cancel_action_rejects_regular_draft_session() {
let mut session = test_session(None);
session.status = Status::Draft;
let allows_cancel_action = session.allows_cancel_action();
assert!(!allows_cancel_action);
}
#[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);
}
}