use std::collections::VecDeque;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
pub use ag_agent::{SessionDiffState, SessionStats, SpeedMode};
pub use ag_session::{
ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary, SessionId, SessionRole,
SessionStatus as Status, activity_day_key_with_offset,
};
use tokio::sync::Mutex as AsyncMutex;
use tokio_util::sync::CancellationToken;
use super::agent::{AgentSelection, ReasoningLevel};
use super::session_message::SessionTranscript;
use crate::domain::question::QuestionItem;
use crate::domain::transient_message::{
TransientMessage, TransientMessageAnchor, TransientMessageBody, TransientMessageLifecycle,
TransientMessageSlot, TransientMessageStore,
};
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, 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",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionDiffStats {
Known {
added_lines: u64,
deleted_lines: u64,
has_diff: bool,
session_size: SessionSize,
},
Unknown,
}
impl SessionDiffStats {
pub fn from_diff(diff: &str) -> Self {
let (added_lines, deleted_lines) = SessionStats::line_change_counts(diff);
Self::Known {
added_lines,
deleted_lines,
has_diff: !diff.trim().is_empty(),
session_size: SessionSize::from_diff(diff),
}
}
pub fn diff_state(self) -> SessionDiffState {
match self {
Self::Known { has_diff: true, .. } => SessionDiffState::Present,
Self::Known {
has_diff: false, ..
} => SessionDiffState::Empty,
Self::Unknown => SessionDiffState::Unknown,
}
}
}
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}")),
}
}
}
#[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, 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 controller_session_id: Option<SessionId>,
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 orchestration_progress: Option<String>,
pub parent_session_id: Option<SessionId>,
pub personality_id: Option<String>,
pub project_name: String,
pub prompt: String,
pub queued_messages: Vec<QueuedMessage>,
pub reasoning_level_override: Option<ReasoningLevel>,
pub published_upstream_ref: Option<String>,
pub questions: Vec<QuestionItem>,
pub review_request: Option<ReviewRequest>,
pub role: SessionRole,
pub size: SessionSize,
pub speed_mode: SpeedMode,
pub stats: SessionStats,
pub status: Status,
pub summary: Option<String>,
pub title: Option<String>,
pub transcript: Option<SessionTranscript>,
pub updated_at: i64,
pub(crate) transient_messages: TransientMessageStore,
}
impl Session {
pub(crate) fn latest_user_prompt_position(&self) -> Option<i64> {
self.transcript
.as_ref()?
.messages()
.iter()
.rev()
.find_map(|message| {
(message.kind == crate::domain::session_message::SessionMessageKind::UserPrompt)
.then_some(message.position)
})
}
pub(crate) fn hydrate_summary_transient(&mut self) {
if matches!(
self.status,
Status::Draft | Status::InProgress | Status::Queued | Status::Canceled
) {
self.transient_messages
.retract(TransientMessageSlot::Summary);
return;
}
let Some(summary) = self
.summary
.as_deref()
.map(str::trim)
.filter(|summary| !summary.is_empty())
.map(ToString::to_string)
else {
self.transient_messages
.retract(TransientMessageSlot::Summary);
return;
};
self.transient_messages.upsert(TransientMessage {
anchor: TransientMessageAnchor::AfterCompletedTurn,
body: TransientMessageBody::Markdown(summary),
lifecycle: TransientMessageLifecycle::ClearOnNewTurn,
slot: TransientMessageSlot::Summary,
turn_position: self.latest_user_prompt_position(),
});
}
pub(crate) fn reconcile_transient_messages(&mut self) {
if matches!(self.status, Status::InProgress | Status::Queued)
&& let Some(active_turn_position) = self.latest_user_prompt_position()
{
self.transient_messages
.clear_for_new_turn(active_turn_position);
}
self.hydrate_summary_transient();
}
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::Merged | Status::Done | Status::Canceled
)
}
pub fn allows_fork_action(&self) -> bool {
self.accepts_user_turns()
&& self.role.owns_branch_changes()
&& self.parent_session_id.is_none()
&& !self.is_draft_session()
&& self.status.allows_review_actions()
}
pub fn allows_review_comment_reply(&self) -> bool {
self.accepts_user_turns()
&& self.role.owns_branch_changes()
&& (self.status.allows_review_actions() || self.status == Status::Question)
}
pub fn allows_worktree_open_action(&self) -> bool {
self.status.allows_session_actions()
&& (self.accepts_user_turns()
|| (self.role == SessionRole::OrchestrationWorker && self.status == Status::Review))
}
pub fn owns_branch_changes(&self) -> bool {
self.role.owns_branch_changes()
}
pub fn accepts_user_turns(&self) -> bool {
self.role.accepts_user_turns()
}
pub fn is_managed(&self) -> bool {
self.role.is_managed()
}
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.accepts_user_turns()
&& (self.status == Status::InProgress
|| self.status.allows_review_actions()
|| (self.status == Status::Draft
&& (self.is_draft_session() || self.role == SessionRole::Orchestrator)))
}
pub fn allows_terminal_continuation(&self) -> bool {
self.role == SessionRole::Worker && 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) -> ReasoningLevel {
self.reasoning_level_override.unwrap_or_default()
}
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 has_review_request(&self) -> bool {
self.review_request.is_some()
}
pub fn can_sync_review_request(&self) -> bool {
let has_forge_context = self.published_upstream_ref.is_some() || self.has_review_request();
has_forge_context && matches!(self.status, Status::Review | Status::AgentReview)
}
pub fn publish_pull_request_action(&self) -> Option<PublishBranchAction> {
let is_publish_active = self
.transient_messages
.get(TransientMessageSlot::BranchPublish)
.is_some_and(|message| message.body.is_pending_indicator());
(self.accepts_user_turns()
&& self.owns_branch_changes()
&& (self.status.allows_review_actions() || self.status == Status::InProgress)
&& !is_publish_active)
.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, String)> {
self.non_empty_summary()
.map(|summary| ("Previous session summary", summary.to_string()))
.or_else(|| {
self.non_empty_transcript()
.map(|transcript| ("Previous session transcript", transcript))
})
.or_else(|| {
self.non_empty_prompt()
.map(|prompt| ("Previous session prompt", prompt.to_string()))
})
}
fn non_empty_summary(&self) -> Option<&str> {
self.summary
.as_deref()
.and_then(Self::trimmed_non_empty_text)
}
fn non_empty_transcript(&self) -> Option<String> {
self.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
}
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_merge_session_branch_in_stack(sessions: &[Session], session_id: &str) -> bool {
let Some(stack) = SessionStack::for_session(sessions, session_id) else {
return false;
};
stack.requested_session.review_request.is_none()
&& !stack.has_branch_mutating_member_except(session_id)
}
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::Merged | 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)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct QueuedMessage {
order: u64,
prompt: TurnPrompt,
transcript_text: String,
}
impl QueuedMessage {
pub(crate) fn new(order: u64, prompt: TurnPrompt) -> Self {
let transcript_text = prompt.transcript_text();
Self {
order,
prompt,
transcript_text,
}
}
pub(crate) fn into_prompt(self) -> TurnPrompt {
self.prompt
}
pub(crate) fn order(&self) -> u64 {
self.order
}
pub(crate) fn prompt(&self) -> &TurnPrompt {
&self.prompt
}
pub(crate) fn transcript_text(&self) -> &str {
&self.transcript_text
}
}
pub struct SessionHandles {
pub branch_operation_lock: Arc<AsyncMutex<()>>,
pub cancel_token: Arc<Mutex<CancellationToken>>,
pub child_pid: Arc<Mutex<Option<u32>>>,
pub queued_messages: Arc<Mutex<VecDeque<QueuedMessage>>>,
pub queued_work_sequence: Arc<AtomicU64>,
pub status: Arc<Mutex<Status>>,
pub transcript: Arc<Mutex<SessionTranscript>>,
transcript_is_hydrated: AtomicBool,
}
impl SessionHandles {
pub fn new(status: Status) -> Self {
Self {
branch_operation_lock: Arc::new(AsyncMutex::new(())),
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
child_pid: Arc::new(Mutex::new(None)),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
queued_work_sequence: Arc::new(AtomicU64::new(0)),
status: Arc::new(Mutex::new(status)),
transcript: Arc::new(Mutex::new(SessionTranscript::default())),
transcript_is_hydrated: AtomicBool::new(true),
}
}
pub(crate) fn new_unloaded(status: Status) -> Self {
Self {
branch_operation_lock: Arc::new(AsyncMutex::new(())),
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
child_pid: Arc::new(Mutex::new(None)),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
queued_work_sequence: Arc::new(AtomicU64::new(0)),
status: Arc::new(Mutex::new(status)),
transcript: Arc::new(Mutex::new(SessionTranscript::default())),
transcript_is_hydrated: AtomicBool::new(false),
}
}
pub fn new_with_transcript(status: Status, transcript: SessionTranscript) -> Self {
Self {
branch_operation_lock: Arc::new(AsyncMutex::new(())),
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
child_pid: Arc::new(Mutex::new(None)),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
queued_work_sequence: Arc::new(AtomicU64::new(0)),
status: Arc::new(Mutex::new(status)),
transcript: Arc::new(Mutex::new(transcript)),
transcript_is_hydrated: AtomicBool::new(true),
}
}
pub(crate) fn transcript_snapshot_with_loaded(
&self,
loaded_transcript: Option<&SessionTranscript>,
) -> Option<SessionTranscript> {
let Ok(mut transcript) = self.transcript.lock() else {
return None;
};
if !self.transcript_is_hydrated.load(Ordering::Acquire)
&& let Some(loaded_transcript) = loaded_transcript
{
*transcript = Self::merge_unloaded_transcript(loaded_transcript, &transcript);
self.transcript_is_hydrated.store(true, Ordering::Release);
}
if transcript.is_empty() {
return None;
}
Some(transcript.clone())
}
pub(crate) fn next_queued_work_order(&self) -> u64 {
self.queued_work_sequence.fetch_add(1, Ordering::Relaxed)
}
pub fn queued_message_snapshot(&self) -> Vec<QueuedMessage> {
self.queued_messages
.lock()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
fn merge_unloaded_transcript(
loaded_transcript: &SessionTranscript,
live_transcript: &SessionTranscript,
) -> SessionTranscript {
let mut messages = loaded_transcript.messages().to_vec();
for live_message in live_transcript.messages() {
if let Some(loaded_message) = messages
.iter()
.find(|message| message.position == live_message.position)
{
if loaded_message == live_message {
continue;
}
let next_position = messages
.last()
.map_or(0, |message| message.position.saturating_add(1));
let mut appended_message = live_message.clone();
appended_message.position = next_position;
messages.push(appended_message);
} else {
messages.push(live_message.clone());
}
}
SessionTranscript::new(messages)
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use super::*;
use crate::domain::agent::AgentModel;
use crate::domain::transient_message::QueuedAction;
use crate::test_support::SessionFixtureBuilder;
#[test]
fn test_activity_day_key_with_offset_applies_offsets_at_day_boundaries() {
let end_of_utc_day = 86_399_i64;
let start_of_utc_day = 86_400_i64;
let positive_offset_day_key = activity_day_key_with_offset(end_of_utc_day, 3_600);
let negative_offset_day_key = activity_day_key_with_offset(start_of_utc_day, -3_600);
assert_eq!(positive_offset_day_key, 1);
assert_eq!(negative_offset_day_key, 0);
}
#[test]
fn test_transcript_snapshot_with_loaded_returns_none_for_poisoned_transcript_lock() {
let handles = SessionHandles::new_unloaded(Status::Review);
let transcript = Arc::clone(&handles.transcript);
let poison_transcript = |transcript: Arc<Mutex<SessionTranscript>>, should_poison: bool| {
let _transcript = transcript
.lock()
.expect("transcript lock should initially be available");
assert!(!should_poison, "poison transcript lock");
};
poison_transcript(Arc::clone(&transcript), false);
let poison_result = std::thread::spawn(move || poison_transcript(transcript, true)).join();
assert!(poison_result.is_err());
let snapshot = handles.transcript_snapshot_with_loaded(None);
assert_eq!(snapshot, None);
}
#[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 merged_session = SessionFixtureBuilder::new().status(Status::Merged).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_merged_child = merged_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_merged_child);
assert!(!allows_done_child);
assert!(!allows_canceled_child);
}
#[test]
fn test_allows_fork_action_accepts_review_ready_materialized_sessions() {
let review_session = SessionFixtureBuilder::new()
.draft(false)
.status(Status::Review)
.build();
let agent_review_session = SessionFixtureBuilder::new()
.draft(false)
.status(Status::AgentReview)
.build();
let allows_review_fork = review_session.allows_fork_action();
let allows_agent_review_fork = agent_review_session.allows_fork_action();
assert!(allows_review_fork);
assert!(allows_agent_review_fork);
}
#[test]
fn test_allows_fork_action_rejects_drafts_children_active_and_terminal_sessions() {
let draft_review_session = SessionFixtureBuilder::new()
.draft(true)
.status(Status::Review)
.build();
let child_review_session = SessionFixtureBuilder::new()
.draft(false)
.parent_session_id(Some(SessionId::from("parent-session")))
.status(Status::Review)
.build();
let in_progress_session = SessionFixtureBuilder::new()
.draft(false)
.status(Status::InProgress)
.build();
let done_session = SessionFixtureBuilder::new()
.draft(false)
.status(Status::Done)
.build();
let orchestrator_session = SessionFixtureBuilder::new()
.draft(false)
.role(SessionRole::Orchestrator)
.status(Status::Review)
.build();
let allows_draft_fork = draft_review_session.allows_fork_action();
let allows_child_fork = child_review_session.allows_fork_action();
let allows_active_fork = in_progress_session.allows_fork_action();
let allows_done_fork = done_session.allows_fork_action();
let allows_orchestrator_fork = orchestrator_session.allows_fork_action();
assert!(!allows_draft_fork);
assert!(!allows_child_fork);
assert!(!allows_active_fork);
assert!(!allows_done_fork);
assert!(!allows_orchestrator_fork);
assert!(!orchestrator_session.owns_branch_changes());
}
#[test]
fn test_allows_review_comment_reply_accepts_review_or_question_sessions() {
let statuses = [Status::Review, Status::AgentReview, Status::Question];
let reply_permissions = statuses.map(|status| {
SessionFixtureBuilder::new()
.status(status)
.build()
.allows_review_comment_reply()
});
assert_eq!(reply_permissions, [true, true, true]);
}
#[test]
fn test_allows_review_comment_reply_rejects_non_reply_or_managed_session() {
let session = SessionFixtureBuilder::new()
.status(Status::InProgress)
.build();
let managed_session = SessionFixtureBuilder::new()
.role(SessionRole::OrchestrationWorker)
.status(Status::Review)
.build();
let allows_reply = session.allows_review_comment_reply();
let managed_allows_reply = managed_session.allows_review_comment_reply();
assert!(!allows_reply);
assert!(!managed_allows_reply);
}
#[test]
fn test_allows_worktree_open_action_accepts_managed_worker_only_in_review() {
let statuses = [Status::InProgress, Status::Review, Status::AgentReview];
let open_permissions = statuses.map(|status| {
SessionFixtureBuilder::new()
.role(SessionRole::OrchestrationWorker)
.status(status)
.build()
.allows_worktree_open_action()
});
let research_permission = SessionFixtureBuilder::new()
.role(SessionRole::OrchestrationResearcher)
.status(Status::Review)
.build()
.allows_worktree_open_action();
assert_eq!(open_permissions, [false, true, false]);
assert!(!research_permission);
}
#[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_merge_session_branch_in_stack_allows_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_merge_parent = can_merge_session_branch_in_stack(&sessions, "parent-session");
assert!(can_merge_parent);
}
#[test]
fn test_can_merge_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_merge_review_child = can_merge_session_branch_in_stack(&sessions, "review-child");
assert!(!can_merge_review_child);
}
#[test]
fn test_can_merge_session_branch_in_stack_blocks_linked_review_request() {
let review_request = 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: "Review request".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
},
};
let session = SessionFixtureBuilder::new()
.id("linked-session")
.review_request(Some(review_request))
.status(Status::Review)
.build();
let sessions = vec![session];
let can_merge_session = can_merge_session_branch_in_stack(&sessions, "linked-session");
assert!(!can_merge_session);
}
#[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::Merged,
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 merged_status_is_read_only_and_only_transitions_to_done() {
let merged_status = Status::Merged;
let review_status = Status::Review;
let merged_is_read_only = merged_status.is_read_only();
let review_is_read_only = review_status.is_read_only();
let review_can_merge = review_status.can_transition_to(merged_status);
let merged_can_finish = merged_status.can_transition_to(Status::Done);
let merged_can_repeat = merged_status.can_transition_to(Status::Merged);
let merged_can_reopen = merged_status.can_transition_to(Status::Review);
assert!(merged_is_read_only);
assert!(!review_is_read_only);
assert!(review_can_merge);
assert!(merged_can_finish);
assert!(merged_can_repeat);
assert!(!merged_can_reopen);
}
#[test]
fn test_reconcile_transient_messages_retains_summary_during_branch_workflow() {
let statuses = [Status::Rebasing, Status::Merging];
let sessions = statuses.map(|status| {
let mut session = SessionFixtureBuilder::new()
.status(status)
.summary(Some("Completed summary".to_string()))
.build();
session.reconcile_transient_messages();
session
});
for session in sessions {
let summary = session
.transient_messages
.get(TransientMessageSlot::Summary)
.expect("branch workflow should retain the completed summary");
assert_eq!(summary.body.text(), "Completed summary");
}
}
#[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_session_actions_for_idle_interactive_states() {
let expected_statuses = [
Status::Draft,
Status::Review,
Status::AgentReview,
Status::Question,
];
let allowed_statuses: Vec<Status> = Status::ALL
.into_iter()
.filter(|status| status.allows_session_actions())
.collect();
assert_eq!(allowed_statuses, expected_statuses);
}
#[test]
fn test_status_allows_chat_composer_during_idle_and_queueable_states() {
let expected_statuses = [
Status::Draft,
Status::InProgress,
Status::Review,
Status::AgentReview,
Status::Question,
Status::Rebasing,
];
let allowed_statuses: Vec<Status> = Status::ALL
.into_iter()
.filter(|status| status.allows_chat_composer())
.collect();
assert_eq!(allowed_statuses, expected_statuses);
}
#[test]
fn test_status_allows_diff_view_for_review_ready_and_read_only_states() {
let expected_statuses = [Status::Review, Status::AgentReview, Status::Merged];
let allowed_statuses: Vec<Status> = Status::ALL
.into_iter()
.filter(|status| status.allows_diff_view())
.collect();
assert_eq!(allowed_statuses, expected_statuses);
}
#[test]
fn test_status_allows_rebase_action_for_review_ready_and_in_progress_states() {
let expected_statuses = [Status::InProgress, Status::Review, Status::AgentReview];
let allowed_statuses: Vec<Status> = Status::ALL
.into_iter()
.filter(|status| status.allows_rebase_action())
.collect();
assert_eq!(allowed_statuses, expected_statuses);
}
#[test]
fn test_status_allows_terminal_continuation_for_terminal_session_outcomes() {
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 session_diff_stats_distinguish_empty_and_binary_diffs() {
let empty_diff = "";
let binary_diff = "diff --git a/image.png b/image.png\nBinary files differ\n";
let empty_stats = SessionDiffStats::from_diff(empty_diff);
let binary_stats = SessionDiffStats::from_diff(binary_diff);
assert_eq!(empty_stats.diff_state(), SessionDiffState::Empty);
assert_eq!(binary_stats.diff_state(), SessionDiffState::Present);
}
#[test]
fn test_effective_reasoning_level_uses_stable_fallback_when_value_is_missing() {
let session = test_session(None);
let effective_reasoning_level = session.effective_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::High);
}
#[test]
fn test_effective_reasoning_level_prefers_session_override() {
let session = test_session(Some(ReasoningLevel::High));
let effective_reasoning_level = session.effective_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::High);
}
#[test]
fn test_effective_reasoning_level_uses_stable_fallback_after_value_is_cleared() {
let mut session = test_session(Some(ReasoningLevel::XHigh));
session.reasoning_level_override = None;
let effective_reasoning_level = session.effective_reasoning_level();
assert_eq!(effective_reasoning_level, ReasoningLevel::High);
}
#[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()))
.transcript("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_uses_summary_for_canceled_session() {
let session = SessionFixtureBuilder::new()
.status(Status::Canceled)
.project_name("project-beta")
.summary(Some("# Summary\n\nResume the remaining work.".to_string()))
.transcript("assistant transcript")
.title(Some("Canceled session".to_string()))
.build();
let continuation_prompt_seed = session
.continuation_prompt_seed()
.expect("expected canceled continuation prompt seed");
assert!(continuation_prompt_seed.contains(TERMINAL_CONTINUATION_PROMPT_INTRO));
assert!(continuation_prompt_seed.contains("Previous session: Canceled session"));
assert!(continuation_prompt_seed.contains("Project: project-beta"));
assert!(continuation_prompt_seed.contains("Status: Canceled"));
assert!(
continuation_prompt_seed
.contains("Previous session summary:\n# Summary\n\nResume the remaining work.")
);
assert!(!continuation_prompt_seed.contains("assistant transcript"));
}
#[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_respects_review_session_capabilities() {
let worker = SessionFixtureBuilder::new().status(Status::Review).build();
let orchestrator = SessionFixtureBuilder::new()
.role(SessionRole::Orchestrator)
.status(Status::Review)
.build();
let managed_worker = SessionFixtureBuilder::new()
.role(SessionRole::OrchestrationWorker)
.status(Status::Review)
.build();
let actions = [
worker.publish_pull_request_action(),
orchestrator.publish_pull_request_action(),
managed_worker.publish_pull_request_action(),
];
assert_eq!(
actions,
[Some(PublishBranchAction::PublishPullRequest), None, None]
);
}
#[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,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: SpeedMode::default(),
stats: SessionStats::default(),
status: Status::AgentReview,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
};
let action = session.publish_pull_request_action();
assert_eq!(action, Some(PublishBranchAction::PublishPullRequest));
}
#[test]
fn test_publish_pull_request_action_returns_none_while_publish_is_active() {
for body in [
TransientMessageBody::Queued(QueuedAction::new(
0,
"publish after this turn".to_string(),
)),
TransientMessageBody::Loading("Publishing review request...".to_string()),
] {
let mut session = crate::test_support::session_fixture("session-id", Status::Review);
session.transient_messages.upsert(TransientMessage {
anchor: TransientMessageAnchor::Tail,
body,
lifecycle: TransientMessageLifecycle::UntilResolved,
slot: TransientMessageSlot::BranchPublish,
turn_position: None,
});
let action = session.publish_pull_request_action();
assert_eq!(action, None);
}
}
#[test]
fn test_publish_pull_request_action_queues_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,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_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()),
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: SpeedMode::default(),
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
};
let action = session.publish_pull_request_action();
assert_eq!(action, Some(PublishBranchAction::PublishPullRequest));
}
#[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,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_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()),
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: SpeedMode::default(),
stats: SessionStats::default(),
status: Status::Done,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
};
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,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: SpeedMode::default(),
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
};
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,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: "project".to_string(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: SpeedMode::default(),
stats: SessionStats::default(),
status: Status::InProgress,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
};
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_has_review_request_reports_link_presence() {
let session_without_review_request = test_session(None);
let mut session_with_review_request = test_session(None);
session_with_review_request.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(),
},
});
let has_no_link = session_without_review_request.has_review_request();
let has_link = session_with_review_request.has_review_request();
assert!(!has_no_link);
assert!(has_link);
}
#[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_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_draft_orchestrator() {
let mut session = test_session(None);
session.status = Status::Draft;
session.role = SessionRole::Orchestrator;
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);
}
}